diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3da4ea36..29bcb37b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -146,6 +146,8 @@ jobs: AWS_SES_ACCESS_KEY_SECRET=${{ secrets.AWS_SES_ACCESS_KEY_SECRET }} AWS_SES_DOMAIN=${{ secrets.AWS_SES_DOMAIN }} AWS_REGION=${{ secrets.AWS_REGION }} + BACKOFFICE_SESSION_COOKIE_NAME=${{ vars.BACKOFFICE_SESSION_COOKIE_NAME }} + BACKOFFICE_SESSION_COOKIE_SECRET=${{ secrets.BACKOFFICE_SESSION_COOKIE_SECRET }} context: . cache-from: type=gha cache-to: type=gha,mode=max @@ -155,28 +157,28 @@ jobs: ${{ steps.login-ecr.outputs.registry }}/${{ secrets.API_REPOSITORY_NAME }}:${{ github.sha }} ${{ steps.login-ecr.outputs.registry }}/${{ secrets.API_REPOSITORY_NAME }}:${{ needs.set_environment_name.outputs.env_name }} - build_admin: + build_backoffice: needs: [ set_environment_name ] environment: name: ${{ needs.set_environment_name.outputs.env_name }} runs-on: ubuntu-latest - name: Build Admin image and push to Amazon ECR + name: Build Backoffice image and push to Amazon ECR steps: - name: Checkout code uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 - id: admin-changes + id: backoffice-changes with: filters: | - admin: - - 'admin/**' + backoffice: + - 'backoffice/**' - '.github/workflows/**' shared: - 'shared/**' - name: Configure AWS credentials - if: ${{ github.event_name == 'workflow_dispatch' || steps.admin-changes.outputs.admin == 'true' }} + if: ${{ github.event_name == 'workflow_dispatch' || steps.backoffice-changes.outputs.backoffice == 'true' }} uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.PIPELINE_USER_ACCESS_KEY_ID }} @@ -184,18 +186,18 @@ jobs: aws-region: ${{ secrets.AWS_REGION }} - name: Login to Amazon ECR - if: ${{ github.event_name == 'workflow_dispatch' || steps.admin-changes.outputs.admin == 'true' }} + if: ${{ github.event_name == 'workflow_dispatch' || steps.backoffice-changes.outputs.backoffice == 'true' }} id: login-ecr uses: aws-actions/amazon-ecr-login@v2 with: mask-password: 'true' - name: Set up Docker Buildx - if: ${{ github.event_name == 'workflow_dispatch' || steps.admin-changes.outputs.admin == 'true' }} + if: ${{ github.event_name == 'workflow_dispatch' || steps.backoffice-changes.outputs.backoffice == 'true' }} uses: docker/setup-buildx-action@v3 - name: Build, tag, and push Admin image to Amazon ECR - if: ${{ github.event_name == 'workflow_dispatch' || steps.admin-changes.outputs.admin == 'true' }} + if: ${{ github.event_name == 'workflow_dispatch' || steps.backoffice-changes.outputs.backoffice == 'true' }} uses: docker/build-push-action@v6 with: build-args: | @@ -205,10 +207,12 @@ jobs: DB_USERNAME=${{ secrets.DB_USERNAME }} DB_PASSWORD=${{ secrets.DB_PASSWORD }} API_URL=${{ vars.NEXT_PUBLIC_API_URL }} + BACKOFFICE_SESSION_COOKIE_NAME=${{ vars.BACKOFFICE_SESSION_COOKIE_NAME }} + BACKOFFICE_SESSION_COOKIE_SECRET=${{ secrets.BACKOFFICE_SESSION_COOKIE_SECRET }} context: . cache-from: type=gha cache-to: type=gha,mode=max - file: ./admin/Dockerfile + file: ./backoffice/Dockerfile push: true tags: | ${{ steps.login-ecr.outputs.registry }}/${{ secrets.ADMIN_REPOSITORY_NAME }}:${{ github.sha }} @@ -217,7 +221,7 @@ jobs: deploy: name: Deploy Services to Amazon EBS - needs: [ set_environment_name, build_client, build_api, build_admin ] + needs: [ set_environment_name, build_client, build_api, build_backoffice] runs-on: ubuntu-latest environment: name: ${{ needs.set_environment_name.outputs.env_name }} @@ -258,7 +262,7 @@ jobs: restart: always ports: - 4000:4000 - admin: + backoffice: image: $ECR_REGISTRY/$ECR_REPOSITORY_ADMIN:$IMAGE_TAG restart: always ports: @@ -274,7 +278,7 @@ jobs: depends_on: - api - client - - admin + - backoffice EOF - name: Generate zip file diff --git a/admin/resources/projects/projects.resource.ts b/admin/resources/projects/projects.resource.ts deleted file mode 100644 index 0503371e..00000000 --- a/admin/resources/projects/projects.resource.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { - ActionContext, - ActionRequest, - ActionResponse, - BaseRecord, - ResourceWithOptions, -} from "adminjs"; -import { dataSource } from "../../datasource.js"; -import { Project } from "@shared/entities/projects.entity.js"; -import { Country } from "@shared/entities/country.entity.js"; -import { - COMMON_RESOURCE_LIST_PROPERTIES, - GLOBAL_COMMON_PROPERTIES, -} from "../common/common.resources.js"; - -export const ProjectsResource: ResourceWithOptions = { - resource: Project, - options: { - properties: { - ...GLOBAL_COMMON_PROPERTIES, - ...COMMON_RESOURCE_LIST_PROPERTIES, - }, - listProperties: [ - "projectName", - "projectSize", - "projectSizeFilter", - "abatementPotential", - "totalCostNPV", - "costPerTCO2eNPV", - "initialPriceAssumption", - "restorationActivity", - "projectSizeFilter", - "priceType", - ], - sort: { - sortBy: "projectName", - direction: "asc", - }, - navigation: { - name: "Data Management", - icon: "Database", - }, - actions: { - list: { - after: async ( - request: ActionRequest, - response: ActionResponse, - context: ActionContext, - ) => { - const { records } = context; - const projectDataRepo = dataSource.getRepository(Project); - const queryBuilder = projectDataRepo - .createQueryBuilder("project") - .leftJoin(Country, "country", "project.countryCode = country.code") - .select("project.id", "id") - .addSelect("project.projectName", "projectName") - .addSelect("project.ecosystem", "ecosystem") - .addSelect("project.activity", "activity") - .addSelect("project.restorationActivity", "restorationActivity") - .addSelect("country.name", "countryName") - .addSelect("project.projectSize", "projectSize") - .addSelect("project.projectSizeFilter", "projectSizeFilter") - .addSelect("project.abatementPotential", "abatementPotential") - .addSelect("project.totalCostNPV", "totalCostNPV") - .addSelect("project.costPerTCO2eNPV", "costPerTCO2eNPV") - .addSelect("project.totalCost", "totalCost") - .addSelect("project.costPerTCO2e", "costPerTCO2e") - .addSelect("project.priceType", "priceType") - .addSelect( - "project.initialPriceAssumption", - "initialPriceAssumption", - ); - - if (records?.length) { - queryBuilder.andWhere("project.id IN (:...ids)", { - ids: records.map((r) => r.params.id), - }); - } - - const result = await queryBuilder.getRawMany(); - - return { - ...request, - records: records!.map((record: BaseRecord) => { - record.params = result.find((q) => q.id === record.params.id); - return record; - }), - }; - }, - }, - }, - }, -}; diff --git a/api/Dockerfile b/api/Dockerfile index 9c6ce05f..53c728a3 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -17,6 +17,8 @@ ARG AWS_SES_ACCESS_KEY_ID ARG AWS_SES_ACCESS_KEY_SECRET ARG AWS_SES_DOMAIN ARG AWS_REGION +ARG BACKOFFICE_SESSION_COOKIE_NAME +ARG BACKOFFICE_SESSION_COOKIE_SECRET ENV DB_HOST $DB_HOST ENV DB_PORT $DB_PORT @@ -35,6 +37,8 @@ ENV AWS_SES_ACCESS_KEY_ID $AWS_SES_ACCESS_KEY_ID ENV AWS_SES_ACCESS_KEY_SECRET $AWS_SES_ACCESS_KEY_SECRET ENV AWS_SES_DOMAIN $AWS_SES_DOMAIN ENV AWS_REGION $AWS_REGION +ENV BACKOFFICE_SESSION_COOKIE_NAME $BACKOFFICE_SESSION_COOKIE_NAME +ENV BACKOFFICE_SESSION_COOKIE_SECRET $BACKOFFICE_SESSION_COOKIE_SECRET WORKDIR /app diff --git a/api/package.json b/api/package.json index 8ef75297..77d96691 100644 --- a/api/package.json +++ b/api/package.json @@ -31,7 +31,7 @@ "class-transformer": "catalog:", "class-validator": "catalog:", "dotenv": "16.4.5", - "financejs": "^4.1.0", + "financial": "^0.2.4", "jsonapi-serializer": "^3.6.9", "lodash": "^4.17.21", "nestjs-base-service": "catalog:", @@ -43,6 +43,7 @@ "reflect-metadata": "catalog:", "rxjs": "^7.8.1", "typeorm": "catalog:", + "uid-safe": "^2.1.5", "xlsx": "^0.18.5", "zod": "catalog:" }, @@ -61,6 +62,7 @@ "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", "@types/supertest": "^6.0.0", + "@types/uid-safe": "^2.1.5", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.42.0", diff --git a/api/src/modules/api-events/events.enum.ts b/api/src/modules/api-events/events.enum.ts index 09923486..e81fd4a1 100644 --- a/api/src/modules/api-events/events.enum.ts +++ b/api/src/modules/api-events/events.enum.ts @@ -7,6 +7,8 @@ export enum API_EVENT_TYPES { EXCEL_IMPORT_FAILED = 'system.excel_import.failed', EXCEL_IMPORT_SUCCESS = 'system.excel_import.success', EXCEL_IMPORT_STARTED = 'system.excel_import.started', + CUSTOM_PROJECT_SAVED = 'custom_project.saved', + ERROR_SAVING_CUSTOM_PROJECT = 'custom_project.error_saving', // More events to come.... } diff --git a/api/src/modules/auth/auth.module.ts b/api/src/modules/auth/auth.module.ts index 7d6b559b..f12331a1 100644 --- a/api/src/modules/auth/auth.module.ts +++ b/api/src/modules/auth/auth.module.ts @@ -6,6 +6,7 @@ import { AuthenticationModule } from '@api/modules/auth/authentication.module'; import { RequestPasswordRecoveryCommandHandler } from '@api/modules/auth/commands/request-password-recovery-command.handler'; import { NewUserEventHandler } from '@api/modules/admin/events/handlers/new-user-event.handler'; import { PasswordRecoveryRequestedEventHandler } from '@api/modules/auth/events/handlers/password-recovery-requested.handler'; +import { BackofficeService } from './backoffice.service'; @Module({ imports: [AuthenticationModule, NotificationsModule], @@ -15,6 +16,7 @@ import { PasswordRecoveryRequestedEventHandler } from '@api/modules/auth/events/ RequestPasswordRecoveryCommandHandler, NewUserEventHandler, PasswordRecoveryRequestedEventHandler, + BackofficeService, ], exports: [AuthenticationModule, AuthMailer], }) diff --git a/api/src/modules/auth/authentication.controller.ts b/api/src/modules/auth/authentication.controller.ts index 195de7c9..16619e0c 100644 --- a/api/src/modules/auth/authentication.controller.ts +++ b/api/src/modules/auth/authentication.controller.ts @@ -5,6 +5,7 @@ import { UseInterceptors, ClassSerializerInterceptor, HttpStatus, + Res, } from '@nestjs/common'; import { User } from '@shared/entities/users/user.entity'; import { LocalAuthGuard } from '@api/modules/auth/guards/local-auth.guard'; @@ -21,13 +22,18 @@ import { CommandBus } from '@nestjs/cqrs'; import { RequestPasswordRecoveryCommand } from '@api/modules/auth/commands/request-password-recovery.command'; import { EmailConfirmation } from '@api/modules/auth/strategies/email-update.strategy'; import { ROLES } from '@shared/entities/users/roles.enum'; +import { Response } from 'express'; +import { ApiConfigService } from '../config/app-config.service'; +import { BackofficeService } from './backoffice.service'; @Controller() @UseInterceptors(ClassSerializerInterceptor) export class AuthenticationController { constructor( private authService: AuthenticationService, + private readonly backofficeService: BackofficeService, private readonly commandBus: CommandBus, + private readonly configService: ApiConfigService, ) {} @Public() @@ -48,9 +54,27 @@ export class AuthenticationController { @Public() @UseGuards(LocalAuthGuard) @TsRestHandler(authContract.login) - async login(@GetUser() user: User): Promise { + async login( + @GetUser() user: User, + @Res({ passthrough: true }) res: Response, + ): Promise { return tsRestHandler(authContract.login, async () => { - const userWithAccessToken = await this.authService.logIn(user); + const [userWithAccessToken, backofficeSession] = + await this.authService.logIn(user); + if (backofficeSession !== undefined) { + const cookieName = this.configService.get( + 'BACKOFFICE_SESSION_COOKIE_NAME', + ); + const cookieValue = + this.backofficeService.generateCookieFromBackofficeSession( + backofficeSession, + ); + res.cookie(cookieName, cookieValue, { + ...backofficeSession.sess.cookie, + sameSite: 'lax', + }); + } + return { body: userWithAccessToken, status: 201, diff --git a/api/src/modules/auth/authentication.module.ts b/api/src/modules/auth/authentication.module.ts index aa00ecb6..5cfda3ca 100644 --- a/api/src/modules/auth/authentication.module.ts +++ b/api/src/modules/auth/authentication.module.ts @@ -14,9 +14,12 @@ import { JwtManager } from '@api/modules/auth/services/jwt.manager'; import { ConfirmAccountStrategy } from '@api/modules/auth/strategies/confirm-account.strategy'; import { PasswordManager } from '@api/modules/auth/services/password.manager'; import { EmailConfirmationJwtStrategy } from '@api/modules/auth/strategies/email-update.strategy'; +import { BackOfficeSession } from '@shared/entities/users/backoffice-session'; +import { TypeOrmModule } from '@nestjs/typeorm'; @Module({ imports: [ + TypeOrmModule.forFeature([BackOfficeSession]), PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.registerAsync({ imports: [ApiConfigModule], diff --git a/api/src/modules/auth/authentication.service.ts b/api/src/modules/auth/authentication.service.ts index a2050c41..8356038d 100644 --- a/api/src/modules/auth/authentication.service.ts +++ b/api/src/modules/auth/authentication.service.ts @@ -1,3 +1,5 @@ +// Does not work without * as uid +import * as uid from 'uid-safe'; import { ConflictException, Injectable, @@ -21,6 +23,13 @@ import { RequestEmailUpdateDto } from '@shared/dtos/users/request-email-update.d import { SendEmailConfirmationEmailCommand } from '@api/modules/notifications/email/commands/send-email-confirmation-email.command'; import { PasswordManager } from '@api/modules/auth/services/password.manager'; import { API_EVENT_TYPES } from '@api/modules/api-events/events.enum'; +import { Repository } from 'typeorm'; +import { + BACKOFFICE_SESSIONS_TABLE, + BackOfficeSession, +} from '@shared/entities/users/backoffice-session'; +import { ROLES } from '@shared/entities/users/roles.enum'; +import { InjectRepository } from '@nestjs/typeorm'; @Injectable() export class AuthenticationService { @@ -31,6 +40,8 @@ export class AuthenticationService { private readonly commandBus: CommandBus, private readonly eventBus: EventBus, private readonly passwordManager: PasswordManager, + @InjectRepository(BackOfficeSession) + private readonly backOfficeSessionRepository: Repository, ) {} async validateUser(email: string, password: string): Promise { const user = await this.usersService.findByEmail(email); @@ -81,9 +92,67 @@ export class AuthenticationService { }; } - async logIn(user: User): Promise { + private async createBackOfficeSession( + user: User, + accessToken: string, + ): Promise { + // We replicate what adminjs does by default using postgres as session storage (the default in memory session storage is not production ready) + // This implementation is not compatible with many devices per user + await this.backOfficeSessionRepository + .createQueryBuilder() + .delete() + .from(BACKOFFICE_SESSIONS_TABLE) + .where(`sess -> 'adminUser' ->> 'id' = :id`, { id: user.id }) + .execute(); + + const currentDate = new Date(); + const sessionExpirationDate = new Date( + Date.UTC( + currentDate.getUTCFullYear() + 1, + currentDate.getUTCMonth(), + currentDate.getUTCDate(), + currentDate.getUTCHours(), + currentDate.getUTCMinutes(), + currentDate.getUTCSeconds(), + ), + ); + const backofficeSession: BackOfficeSession = { + sid: await uid(24), + sess: { + cookie: { + secure: false, + httpOnly: true, + path: '/', + }, + adminUser: { + id: user.id, + email: user.email, + name: user.name, + partnerName: user.partnerName, + isActive: true, + role: user.role, + createdAt: user.createdAt, + accessToken, + }, + }, + expire: sessionExpirationDate, + }; + await this.backOfficeSessionRepository.insert(backofficeSession); + return backofficeSession; + } + + async logIn(user: User): Promise<[UserWithAccessToken, BackOfficeSession?]> { const { accessToken } = await this.jwtManager.signAccessToken(user.id); - return { user, accessToken }; + if (user.role !== ROLES.ADMIN) { + return [{ user, accessToken }]; + } + + // An adminjs session needs to be created for the admin user + const backofficeSession = await this.createBackOfficeSession( + user, + accessToken, + ); + return [{ user, accessToken }, backofficeSession]; } async signUp(user: User, signUpDto: SignUpDto): Promise { diff --git a/api/src/modules/auth/backoffice.service.ts b/api/src/modules/auth/backoffice.service.ts new file mode 100644 index 00000000..df1c2907 --- /dev/null +++ b/api/src/modules/auth/backoffice.service.ts @@ -0,0 +1,25 @@ +import { BackOfficeSession } from '@shared/entities/users/backoffice-session'; +import * as crypto from 'crypto'; +import { ApiConfigService } from '../config/app-config.service'; +import { Inject } from '@nestjs/common'; + +export class BackofficeService { + constructor( + @Inject(ApiConfigService) + private readonly configService: ApiConfigService, + ) {} + + public generateCookieFromBackofficeSession( + backofficeSession: BackOfficeSession, + ): string { + const cookieSecret = this.configService.get( + 'BACKOFFICE_SESSION_COOKIE_SECRET', + ); + const hmac = crypto + .createHmac('sha256', cookieSecret) + .update(backofficeSession.sid) + .digest('base64') + .replace(/=+$/, ''); + return `s:${backofficeSession.sid}.${hmac}`; + } +} diff --git a/api/src/modules/calculations/assumptions.repository.ts b/api/src/modules/calculations/assumptions.repository.ts index 655e5487..a2c62771 100644 --- a/api/src/modules/calculations/assumptions.repository.ts +++ b/api/src/modules/calculations/assumptions.repository.ts @@ -1,7 +1,7 @@ import { In, Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; import { Injectable } from '@nestjs/common'; -import { ModelAssumptions } from '@shared/entities/model-assumptions.entity'; + import { GetOverridableAssumptionsDTO } from '@shared/dtos/custom-projects/get-overridable-assumptions.dto'; import { ACTIVITY } from '@shared/entities/activity.enum'; import { ECOSYSTEM } from '@shared/entities/ecosystem.enum'; @@ -10,6 +10,25 @@ import { COMMON_OVERRIDABLE_ASSUMPTION_NAMES, ECOSYSTEM_RESTORATION_RATE_NAMES, } from '@shared/schemas/assumptions/assumptions.enums'; +import { OverridableAssumptions } from '@api/modules/custom-projects/dto/project-assumptions.dto'; +import { ModelAssumptions } from '@shared/entities/model-assumptions.entity'; + +const NON_OVERRIDABLE_ASSUMPTION_NAMES_MAP = { + 'Annual cost increase': 'annualCostIncrease', + 'Carbon price': 'carbonPrice', + 'Site specific ecosystem loss rate (if national no national loss rate)': + 'siteSpecificEcosystemLossRate', + 'Interest rate': 'interestRate', + 'Loan repayment schedule': 'loanRepaymentSchedule', + 'Soil Organic carbon release length': 'soilOrganicCarbonReleaseLength', + 'Planting success rate': 'plantingSuccessRate', + 'Default project length': 'defaultProjectLength', +}; + +const SCALING_POINTS_MAP = { + [ACTIVITY.CONSERVATION]: 'Starting point scaling - conservation', + [ACTIVITY.RESTORATION]: 'Starting point scaling - restoration', +}; @Injectable() export class AssumptionsRepository extends Repository { @@ -54,7 +73,58 @@ export class AssumptionsRepository extends Repository { return assumptions; } - async getAllModelAssumptions() { - // TODO: To be implemented. We probably don't want to retrieve by find() as we would need to have a constant-like object for the calculations + async getNonOverridableModelAssumptions( + activity: ACTIVITY, + ): Promise { + const NON_OVERRIDABLE_ASSUMPTION_NAMES = Object.keys( + NON_OVERRIDABLE_ASSUMPTION_NAMES_MAP, + ); + const scalingPointToSelect = SCALING_POINTS_MAP[activity]; + const assumptions: ModelAssumptions[] = await this.createQueryBuilder( + 'model_assumptions', + ) + .select(['name', 'value']) + .where({ + name: In([...NON_OVERRIDABLE_ASSUMPTION_NAMES, scalingPointToSelect]), + }) + .getRawMany(); + // To account for global non overridable assumptions + 1 dynamically selected assumption, the scaling point + if (assumptions.length !== NON_OVERRIDABLE_ASSUMPTION_NAMES.length + 1) { + throw new Error( + 'Not all required non-overridable assumptions were found', + ); + } + + // There is an assumption that is not numeric, that's why the column is marked as string, but we don't seem to be using it. Double check this. + return assumptions.reduce((acc, item) => { + const propertyName = NON_OVERRIDABLE_ASSUMPTION_NAMES_MAP[item.name]; + if (propertyName) { + acc[propertyName] = parseFloat(item.value); + } + if (Object.values(SCALING_POINTS_MAP).includes(item.name)) { + acc.startingPointScaling = parseFloat(item.value); + } + return acc; + }, {} as NonOverridableModelAssumptions); } } + +/** + * Model assumptions that are not overridable by the user and will be fetched from the database, to then be merged with the user's assumptions + * before being used in the calculations. + */ +export class NonOverridableModelAssumptions { + annualCostIncrease: number; + carbonPrice: number; + siteSpecificEcosystemLossRate: number; + interestRate: number; + // TODO: Is this really non-overridable? + loanRepaymentSchedule: number; + soilOrganicCarbonReleaseLength: number; + plantingSuccessRate: number; + startingPointScaling: number; + defaultProjectLength: number; +} + +export type ModelAssumptionsForCalculations = NonOverridableModelAssumptions & + OverridableAssumptions; diff --git a/api/src/modules/calculations/calculation.engine.ts b/api/src/modules/calculations/calculation.engine.ts index 234920dc..5968d5d1 100644 --- a/api/src/modules/calculations/calculation.engine.ts +++ b/api/src/modules/calculations/calculation.engine.ts @@ -1,27 +1,61 @@ import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { ModelAssumptions } from '@shared/entities/model-assumptions.entity'; -import { Country } from '@shared/entities/country.entity'; -import { ECOSYSTEM } from '@shared/entities/ecosystem.enum'; -import { ACTIVITY } from '@shared/entities/activity.enum'; -import { BaseDataView } from '@shared/entities/base-data.view'; -import { BaseSize } from '@shared/entities/base-size.entity'; +import { + CostCalculator, + ProjectInput, +} from '@api/modules/calculations/cost.calculator'; import { BaseIncrease } from '@shared/entities/base-increase.entity'; +import { BaseSize } from '@shared/entities/base-size.entity'; +import { SequestrationRateCalculator } from '@api/modules/calculations/sequestration-rate.calculator'; +import { RevenueProfitCalculator } from '@api/modules/calculations/revenue-profit.calculator'; +import { + CustomProjectCostDetails, + CustomProjectSummary, + YearlyBreakdown, +} from '@shared/dtos/custom-projects/custom-project-output.dto'; -export type GetBaseData = { - countryCode: Country['code']; - ecosystem: ECOSYSTEM; - activity: ACTIVITY; -}; - -export type BaseDataForCalculation = { - defaultAssumptions: ModelAssumptions[]; - baseData: BaseDataView; - baseSize: BaseSize; - baseIncrease: BaseIncrease; +export type CostOutput = { + costPlans: any; + summary: CustomProjectSummary; + yearlyBreakdown: YearlyBreakdown; + costDetails: { + total: CustomProjectCostDetails; + npv: CustomProjectCostDetails; + }; }; @Injectable() export class CalculationEngine { - constructor(private readonly dataSource: DataSource) {} + constructor() {} + + calculateCostOutput(dto: { + projectInput: ProjectInput; + baseIncrease: BaseIncrease; + baseSize: BaseSize; + }): CostOutput { + const { projectInput, baseIncrease, baseSize } = dto; + const sequestrationRateCalculator = new SequestrationRateCalculator( + projectInput, + ); + const revenueProfitCalculator = new RevenueProfitCalculator( + projectInput, + sequestrationRateCalculator, + ); + + const costCalculator = new CostCalculator( + projectInput, + baseSize, + baseIncrease, + revenueProfitCalculator, + sequestrationRateCalculator, + ); + + const costPlans = costCalculator.initializeCostPlans(); + return { + costPlans, + summary: costCalculator.getSummary(costPlans), + yearlyBreakdown: costCalculator.getYearlyBreakdown(), + costDetails: costCalculator.getCostDetails(costPlans), + }; + } } diff --git a/api/src/modules/calculations/capex-total.calculator.ts b/api/src/modules/calculations/capex-total.calculator.ts deleted file mode 100644 index 41c525ff..00000000 --- a/api/src/modules/calculations/capex-total.calculator.ts +++ /dev/null @@ -1 +0,0 @@ -export class CapexTotalCalculator {} diff --git a/api/src/modules/calculations/conservation-cost.calculator.ts b/api/src/modules/calculations/conservation-cost.calculator.ts deleted file mode 100644 index 11a79460..00000000 --- a/api/src/modules/calculations/conservation-cost.calculator.ts +++ /dev/null @@ -1,709 +0,0 @@ -import { ConservationProject } from '@api/modules/custom-projects/conservation.project'; -import { DEFAULT_STUFF } from '@api/modules/custom-projects/project-config.interface'; -import { BaseIncrease } from '@shared/entities/base-increase.entity'; -import { BaseSize } from '@shared/entities/base-size.entity'; -import { SequestrationRatesCalculator } from '@api/modules/calculations/sequestration-rate.calculator'; -import { - ACTIVITY, - RESTORATION_ACTIVITY_SUBTYPE, -} from '@shared/entities/activity.enum'; -import { RevenueProfitCalculator } from '@api/modules/calculations/revenue-profit.calculators'; -import { Finance } from 'financejs'; - -export class ConservationCostCalculator { - project: ConservationProject; - // TODO: Project length and starting point scaling depend on the activity and it seems to only be used in the calculation, so we can probably remove it from project instantiation - conservationProjectLength: number = DEFAULT_STUFF.CONSERVATION_PROJECT_LENGTH; - startingPointScaling: number = - DEFAULT_STUFF.CONSERVATION_STARTING_POINT_SCALING; - defaultProjectLength: number = DEFAULT_STUFF.DEFAULT_PROJECT_LENGTH; - restorationRate: number = DEFAULT_STUFF.RESTORATION_RATE; - discountRate: number = DEFAULT_STUFF.DISCOUNT_RATE; - // TODO: Maybe instead of using capexTotal and opexTotal, we can use just totalCostPlan if the only difference is the type of cost - baselineReassessmentFrequency: number = - DEFAULT_STUFF.BASELINE_REASSESSMENT_FREQUENCY; - capexTotalCostPlan: { [year: number]: number } = {}; - opexTotalCostPlan: { [year: number]: number } = {}; - totalCostPlan: { [year: number]: number } = {}; - totalCapex: number; - totalCapexNPV: number; - totalOpexNPV: number; - totalNPV: number; - baseIncrease: BaseIncrease; - baseSize: BaseSize; - public sequestrationCreditsCalculator: SequestrationRatesCalculator; - public revenueProfitCalculator: RevenueProfitCalculator; - estimatedRevenuePlan: { [year: number]: number } = {}; - totalRevenue: number; - totalRevenueNPV: number; - totalCreditsPlan: { [year: number]: number } = {}; - creditsIssued: number; - costPertCO2eSequestered: number; - costPerHa: number; - NPVCoveringCosts: number; - financingCost: number; - fundingGapNPV: number; - fundingGapPerTCO2NPV: number; - communityBenefitSharingFundPlan: { [year: number]: number } = {}; - totalCommunityBenefitSharingFundNPV: number; - communityBenefitSharingFund: number; - fundingGap: number; - IRROpex: number; - IRRTotalCost: number; - proforma: any; - constructor( - project: ConservationProject, - baseIncrease: BaseIncrease, - baseSize: BaseSize, - ) { - this.project = project; - this.sequestrationCreditsCalculator = new SequestrationRatesCalculator( - project, - this.conservationProjectLength, - this.defaultProjectLength, - ACTIVITY.CONSERVATION, - RESTORATION_ACTIVITY_SUBTYPE.PLANTING, - ); - this.revenueProfitCalculator = new RevenueProfitCalculator( - this.project, - this.conservationProjectLength, - this.defaultProjectLength, - this.sequestrationCreditsCalculator, - ); - this.baseIncrease = baseIncrease; - this.baseSize = baseSize; - this.capexTotalCostPlan = this.initializeCostPlan(); - this.opexTotalCostPlan = this.initializeCostPlan(); - this.totalCostPlan = this.initializeCostPlan(); - this.calculateCapexTotal(); - this.calculateOpexTotal(); - this.totalCapex = Object.values(this.capexTotalCostPlan).reduce( - (sum, value) => sum + value, - 0, - ); - this.totalCapexNPV = this.calculateNPV( - this.capexTotalCostPlan, - this.discountRate, - ); - this.totalOpexNPV = this.calculateNPV( - this.opexTotalCostPlan, - this.discountRate, - ); - this.totalNPV = this.totalCapexNPV + this.totalOpexNPV; - - this.estimatedRevenuePlan = - this.revenueProfitCalculator.calculateEstimatedRevenue(); - this.totalRevenue = Object.values(this.estimatedRevenuePlan).reduce( - (sum, value) => sum + value, - 0, - ); - this.totalRevenueNPV = this.calculateNPV( - this.estimatedRevenuePlan, - this.discountRate, - ); - this.totalCreditsPlan = - this.sequestrationCreditsCalculator.calculateEstimatedCreditsIssued(); - this.creditsIssued = Object.values(this.totalCreditsPlan).reduce( - (sum, value) => sum + value, - 0, - ); - this.costPertCO2eSequestered = this.totalNPV / this.creditsIssued; - this.costPerHa = this.totalNPV / this.project.projectSizeHa; - this.NPVCoveringCosts = - this.project.carbonRevenuesToCover === 'Opex' - ? this.totalRevenueNPV - this.totalOpexNPV - : this.totalRevenueNPV - this.totalCapexNPV; - - this.financingCost = - this.project.costInputs.financingCost * this.totalCapex; - - this.fundingGapNPV = this.NPVCoveringCosts < 0 ? -this.NPVCoveringCosts : 0; - this.fundingGapPerTCO2NPV = this.fundingGapNPV / this.creditsIssued; - this.communityBenefitSharingFundPlan = - this.calculateCommunityBenefitSharingFund(); - this.totalCommunityBenefitSharingFundNPV = this.calculateNPV( - this.communityBenefitSharingFundPlan, - this.project.discountRate, - ); - this.communityBenefitSharingFund = - this.totalCommunityBenefitSharingFundNPV / this.totalRevenueNPV; - - const referenceNPV = - this.project.carbonRevenuesToCover === 'Opex' - ? this.totalOpexNPV - : this.totalNPV; - this.fundingGap = this.calculateFundingGap( - referenceNPV, - this.totalRevenueNPV, - ); - - this.IRROpex = this.calculateIRR( - this.revenueProfitCalculator.calculateAnnualNetCashFlow( - this.capexTotalCostPlan, - this.opexTotalCostPlan, - ), - this.revenueProfitCalculator.calculateAnnualNetIncome( - this.opexTotalCostPlan, - ), - false, - ); - - this.IRRTotalCost = this.calculateIRR( - this.revenueProfitCalculator.calculateAnnualNetCashFlow( - this.capexTotalCostPlan, - this.opexTotalCostPlan, - ), - this.revenueProfitCalculator.calculateAnnualNetIncome( - this.opexTotalCostPlan, - ), - true, - ); - this.proforma = this.getYearlyCostBreakdown(); - } - - private initializeCostPlan(): { [year: number]: number } { - const costPlan: { [year: number]: number } = {}; - for (let i = -4; i <= this.defaultProjectLength; i++) { - if (i !== 0) { - costPlan[i] = 0; - } - } - return costPlan; - } - - // TODO: CAPEX TOTAL - private calculateCapexTotal(): { [year: number]: number } { - const costFunctions = [ - this.calculateFeasibilityAnalysisCost, - this.calculateConservationPlanningAndAdmin, - this.calculateDataCollectionAndFieldCost, - this.calculateCommunityRepresentation, - this.calculateBlueCarbonProjectPlanning, - this.calculateEstablishingCarbonRights, - this.calculateValidation, - this.calculateImplementationLabor, - ]; - - for (const costFunc of costFunctions) { - const costPlan = costFunc.call(this); - this.aggregateCosts(costPlan, this.capexTotalCostPlan); - } - - return this.capexTotalCostPlan; - } - - private calculateFeasibilityAnalysisCost(): { [year: number]: number } { - const totalBaseCost = this.calculateCostPlan('feasibilityAnalysis'); - const feasibilityAnalysisCostPlan = this.createSimplePlan(totalBaseCost, [ - -4, - ]); - return feasibilityAnalysisCostPlan; - } - - private calculateConservationPlanningAndAdmin(): { [year: number]: number } { - const totalBaseCost = this.calculateCostPlan( - 'conservationPlanningAndAdmin', - ); - const conservationPlanningAndAdminCostPlan = this.createSimplePlan( - totalBaseCost, - [-4, -3, -2, -1], - ); - return conservationPlanningAndAdminCostPlan; - } - - private calculateDataCollectionAndFieldCost(): { [year: number]: number } { - const totalBaseCost = this.calculateCostPlan('dataCollectionAndFieldCost'); - const dataCollectionAndFieldCostPlan = this.createSimplePlan( - totalBaseCost, - [-4, -3, -2], - ); - return dataCollectionAndFieldCostPlan; - } - - private calculateCommunityRepresentation(): { [year: number]: number } { - const totalBaseCost = this.calculateCostPlan('communityRepresentation'); - const projectDevelopmentType = - this.project.costInputs.projectDevelopmentType; - const initialCostPlan = - projectDevelopmentType === 'Development' ? totalBaseCost : 0; - const communityRepresentationCostPlan = this.createSimplePlan( - totalBaseCost, - [-4, -3, -2], - ); - communityRepresentationCostPlan[-4] = initialCostPlan; - return communityRepresentationCostPlan; - } - - private calculateBlueCarbonProjectPlanning(): { [year: number]: number } { - const totalBaseCost = this.calculateCostPlan('blueCarbonProjectPlanning'); - const blueCarbonProjectPlanningCostPlan = this.createSimplePlan( - totalBaseCost, - [-1], - ); - return blueCarbonProjectPlanningCostPlan; - } - - private calculateEstablishingCarbonRights(): { [year: number]: number } { - const totalBaseCost = this.calculateCostPlan('establishingCarbonRights'); - const establishingCarbonRightsCostPlan = this.createSimplePlan( - totalBaseCost, - [-3, -2, -1], - ); - return establishingCarbonRightsCostPlan; - } - - private calculateValidation(): { [year: number]: number } { - const totalBaseCost = this.calculateCostPlan('validation'); - const validationCostPlan = this.createSimplePlan(totalBaseCost, [-1]); - return validationCostPlan; - } - - private calculateImplementationLabor(): { [year: number]: number } { - const baseCost = this.project.costInputs.implementationLabor; - - const areaRestoredOrConservedPlan = - this.sequestrationCreditsCalculator.calculateAreaRestoredOrConserved(); - const implementationLaborCostPlan: { [year: number]: number } = {}; - - for (let year = -4; year <= this.defaultProjectLength; year++) { - if (year !== 0) { - implementationLaborCostPlan[year] = 0; - } - } - - for (let year = 1; year <= this.conservationProjectLength; year++) { - const laborCost = - baseCost * - ((areaRestoredOrConservedPlan[year] || 0) - - (areaRestoredOrConservedPlan[year - 1] || 0)); - implementationLaborCostPlan[year] = laborCost; - } - - return implementationLaborCostPlan; - } - - // TODO: OPEX TOTAL COST CALCULATION - private calculateOpexTotal(): { [year: number]: number } { - const costFunctions = [ - this.calculateMonitoring, - this.calculateMaintenance, - this.calculateCommunityBenefitSharingFund, - this.calculateCarbonStandardFees, - this.calculateBaselineReassessment, - this.calculateMRV, - this.calculateLongTermProjectOperating, - ]; - - for (const costFunc of costFunctions) { - try { - const costPlan = costFunc.call(this); - this.aggregateCosts(costPlan, this.opexTotalCostPlan); - } catch (error) { - console.error(`Error calculating ${costFunc.name}`); - } - } - - return this.opexTotalCostPlan; - } - - private calculateMonitoring(): { [year: number]: number } { - const totalBaseCost = this.calculateCostPlan('monitoring'); - const monitoringCostPlan: { [year: number]: number } = {}; - - for (let year = -4; year <= this.defaultProjectLength; year++) { - if (year !== 0) { - monitoringCostPlan[year] = - year >= 1 && year <= this.defaultProjectLength ? totalBaseCost : 0; - } - } - - return monitoringCostPlan; - } - - private calculateMaintenance(): { [year: number]: number } { - const baseCost = this.project.costInputs.maintenance; - const maintenanceDuration = this.project.costInputs.maintenanceDuration; - const implementationLaborCostPlan = this.calculateImplementationLabor(); - // TODO: Should I get the first year where value is 0 where key is greater or equal than 1? - const firstZeroValue = Number( - Object.keys(implementationLaborCostPlan).find((key) => { - return implementationLaborCostPlan[key] === 0 && Number(key) >= 1; - }), - ); - let maintenanceEndYear: number; - if (this.project.costInputs.projectSizeHa / this.restorationRate <= 20) { - maintenanceEndYear = firstZeroValue + maintenanceDuration - 1; - } else { - maintenanceEndYear = this.defaultProjectLength + maintenanceDuration; - } - const maintenanceCostPlan: { [year: number]: number } = {}; - - // Initialize the cost plan with zeros - for (let year = -4; year <= this.defaultProjectLength; year++) { - if (year !== 0) { - maintenanceCostPlan[year] = 0; - } - } - // For Conservation projects, apply the base cost over the project length - for (let year = 1; year <= this.conservationProjectLength; year++) { - if (year <= maintenanceEndYear) { - maintenanceCostPlan[year] = baseCost; - } - } - - return maintenanceCostPlan; - } - - private calculateCommunityBenefitSharingFund(): { [year: number]: number } { - const baseCost = this.project.costInputs.communityBenefitSharingFund; - const communityBenefitSharingFundCostPlan: { [year: number]: number } = {}; - - for (let year = -4; year <= this.defaultProjectLength; year++) { - if (year !== 0) { - communityBenefitSharingFundCostPlan[year] = 0; - } - } - // TODO: This needs RevenueProfitCalculator to be implemented - - const estimatedRevenue: { [year: number]: number } = - this.revenueProfitCalculator.calculateEstimatedRevenue() || {}; - - for (const year in communityBenefitSharingFundCostPlan) { - if (+year <= this.conservationProjectLength) { - communityBenefitSharingFundCostPlan[+year] = - (estimatedRevenue[+year] || 0) * baseCost; - } - } - - return communityBenefitSharingFundCostPlan; - } - - private calculateCarbonStandardFees(): { [year: number]: number } { - const baseCost = this.project.costInputs.carbonStandardFees; - const carbonStandardFeesCostPlan: { [year: number]: number } = {}; - - for (let year = -4; year <= this.defaultProjectLength; year++) { - if (year !== 0) { - carbonStandardFeesCostPlan[year] = 0; - } - } - const estimatedCreditsIssued: { [year: number]: number } = - this.sequestrationCreditsCalculator.calculateEstimatedCreditsIssued() || - {}; - - for (let year = 1; year <= this.conservationProjectLength; year++) { - carbonStandardFeesCostPlan[year] = - (estimatedCreditsIssued[year] || 0) * baseCost; - } - - return carbonStandardFeesCostPlan; - } - - private calculateBaselineReassessment(): { [year: number]: number } { - const baseCost = this.project.costInputs.baselineReassessment; - const baselineReassessmentCostPlan: { [year: number]: number } = {}; - - for (let year = -4; year <= this.defaultProjectLength; year++) { - if (year !== 0) { - baselineReassessmentCostPlan[year] = 0; - } - } - - for (let year = 1; year <= this.conservationProjectLength; year++) { - if (year % this.baselineReassessmentFrequency === 0) { - baselineReassessmentCostPlan[year] = baseCost; - } - } - - return baselineReassessmentCostPlan; - } - - private calculateMRV(): { [year: number]: number } { - const baseCost = this.project.costInputs.mrv; - const mrvCostPlan: { [year: number]: number } = {}; - - for (let year = -4; year <= this.defaultProjectLength; year++) { - if (year !== 0) { - mrvCostPlan[year] = 0; - } - } - - for (let year = 1; year <= this.conservationProjectLength; year++) { - if (year % this.project.verificationFrequency === 0) { - mrvCostPlan[year] = baseCost; - } - } - - return mrvCostPlan; - } - - private calculateLongTermProjectOperating(): { [year: number]: number } { - const baseCost = this.project.costInputs.longTermProjectOperating; - const longTermProjectOperatingCostPlan: { [year: number]: number } = {}; - - for (let year = -4; year <= this.defaultProjectLength; year++) { - if (year !== 0) { - longTermProjectOperatingCostPlan[year] = 0; - } - } - - for (let year = 1; year <= this.conservationProjectLength; year++) { - longTermProjectOperatingCostPlan[year] = baseCost; - } - - return longTermProjectOperatingCostPlan; - } - - private aggregateCosts( - costPlan: { [year: number]: number }, - totalCostPlan: { [year: number]: number }, - ): void { - for (const yearStr of Object.keys(costPlan)) { - const year = Number(yearStr); - totalCostPlan[year] += costPlan[year]; - } - } - - private calculateCostPlan(baseKey: any): number { - const increasedBy: number = parseFloat(this.baseIncrease[baseKey]); - const baseCostValue: number = parseFloat(this.baseSize[baseKey]); - const sizeDifference = - this.project.projectSizeHa - this.startingPointScaling; - const value = Math.max(Math.round(sizeDifference / baseCostValue), 0); - - const totalBaseCost = baseCostValue + increasedBy * value * baseCostValue; - return totalBaseCost; - } - - private createSimplePlan( - totalBaseCost: number, - years?: number[], - ): { [year: number]: number } { - if (!years) { - years = [-4, -3, -2, -1]; - } - const plan: { [year: number]: number } = {}; - for (const year of years) { - plan[year] = totalBaseCost; - } - return plan; - } - - private calculateNPV( - costPlan: { [year: number]: number }, - discountRate: number, - actualYear: number = -4, - ): number { - let npv = 0; - - for (const [yearStr, cost] of Object.entries(costPlan)) { - const year = Number(yearStr); - - if (year === actualYear) { - npv += cost; - } else if (year > 0) { - npv += cost / Math.pow(1 + discountRate, year + (-actualYear - 1)); - } else { - npv += cost / Math.pow(1 + discountRate, -actualYear + year); - } - } - - return npv; - } - - private calculateFundingGap( - referenceNPV: number, - totalRevenueNPV: number, - ): number { - const value = totalRevenueNPV - referenceNPV; - if (value > 0) { - return 0; - } - return -value; - } - - calculateIRR( - netCashFlow: { [year: number]: number }, - netIncome: { [year: number]: number }, - useCapex: boolean = false, - ): number { - const finance = new Finance(); - const cashFlowArray = useCapex - ? Object.values(netCashFlow) - : Object.values(netIncome); - const [cfs, ...cashFlows] = cashFlowArray; - - // TODO: On first tests, I am only getting negavite values for cashFlows, so the library crashes. For now I am setting them to 0 - let irr: number; - try { - irr = finance.IRR(cfs, ...cashFlows); - } catch (error) { - irr = 0; - } - - return irr; - } - - getYearlyCostBreakdown(): any[] { - // Helper function to extend the cost plan for each year - const extendCostPlan = (costPlan: { [year: number]: number }): number[] => { - return Array.from({ length: this.conservationProjectLength + 4 }) - .map((_, idx) => idx - 4) - .filter((year) => year !== 0) - .map((year) => costPlan[year] ?? 0); - }; - - // Define the years, including 'Total' and 'NPV' - const years: (number | string)[] = [ - ...Array.from({ length: this.conservationProjectLength + 4 }) - .map((_, idx) => idx - 4) - .filter((year) => year !== 0), - 'Total', - 'NPV', - ]; - - // Extend the cost plans for each category - const costPlans = { - feasibility_analysis: this.calculateFeasibilityAnalysisCost(), - conservation_planning_and_admin: - this.calculateConservationPlanningAndAdmin(), - data_collection_and_field: this.calculateDataCollectionAndFieldCost(), - community_representation: this.calculateCommunityRepresentation(), - blue_carbon_project_planning: this.calculateBlueCarbonProjectPlanning(), - establishing_carbon_rights: this.calculateEstablishingCarbonRights(), - validation: this.calculateValidation(), - implementation_labor: this.calculateImplementationLabor(), - monitoring: this.calculateMonitoring(), - maintenance: this.calculateMaintenance(), - community_benefit_sharing_fund: - this.calculateCommunityBenefitSharingFund(), - carbon_standard_fees: this.calculateCarbonStandardFees(), - baseline_reassessment: this.calculateBaselineReassessment(), - MRV: this.calculateMRV(), - long_term_project_operating: this.calculateLongTermProjectOperating(), - capex_total: this.capexTotalCostPlan, - opex_total: this.opexTotalCostPlan, - }; - - // Negate costs to represent outflows - for (const key in costPlans) { - if (costPlans.hasOwnProperty(key)) { - costPlans[key] = Object.fromEntries( - Object.entries(costPlans[key]).map(([k, v]) => [Number(k), -v]), - ); - } - } - - // Create the extended cost structure - const extendedCosts: { [key: string]: number[] } = {}; - for (const [name, plan] of Object.entries(costPlans)) { - extendedCosts[name] = extendCostPlan(plan); - extendedCosts[name].push( - Object.values(plan).reduce((sum, value) => sum + value, 0), // Total - this.calculateNPV(plan, this.project.discountRate), // NPV - ); - } - - // Convert to array of objects with each year as a row - return years.map((year, index) => { - // @ts-ignore - const row: { year: number; [key: string]: number } = { year }; - for (const [name, values] of Object.entries(extendedCosts)) { - row[name] = values[index]; - } - return row; - }); - } - - getSummary(): { [key: string]: string } { - return { - Project: `${this.project.countryCode} ${this.project.ecosystem}\n${this.project.activity} (${this.project.projectSizeHa} ha)`, - name: this.project.name, - '$/tCO2e (total cost, NPV)': `$${this.costPertCO2eSequestered}`, - '$/ha': `$${this.costPerHa}`, - 'NPV covering cost': `$${this.NPVCoveringCosts}`, - 'IRR when priced to cover opex': `${this.IRROpex * 100}%`, - 'IRR when priced to cover total costs': `${this.IRRTotalCost * 100}%`, - 'Total cost (NPV)': `$${this.totalNPV}`, - 'Capital expenditure (NPV)': `$${this.totalCapexNPV}`, - 'Operating expenditure (NPV)': `$${this.totalOpexNPV}`, - 'Credits issued': `${this.creditsIssued}`, - 'Total revenue (NPV)': `$${this.totalRevenueNPV}`, - 'Total revenue (non-discounted)': `$${this.totalRevenue}`, - 'Financing cost': `$${this.financingCost}`, - }; - } - - getCostEstimates(): { costCategory: string; costEstimateUSD: string }[] { - const costCategories = [ - { - name: 'Feasibility Analysis', - cost: this.calculateFeasibilityAnalysisCost(), - }, - { - name: 'Conservation Planning and Admin', - cost: this.calculateConservationPlanningAndAdmin(), - }, - { - name: 'Data Collection and Field', - cost: this.calculateDataCollectionAndFieldCost(), - }, - { - name: 'Community Representation', - cost: this.calculateCommunityRepresentation(), - }, - { - name: 'Blue Carbon Project Planning', - cost: this.calculateBlueCarbonProjectPlanning(), - }, - { - name: 'Establishing Carbon Rights', - cost: this.calculateEstablishingCarbonRights(), - }, - { name: 'OPEX Total Cost', cost: this.opexTotalCostPlan }, - { name: 'Monitoring', cost: this.calculateMonitoring() }, - { name: 'Maintenance', cost: this.calculateMaintenance() }, - { - name: 'Community Benefit Sharing Fund', - cost: this.calculateCommunityBenefitSharingFund(), - }, - { - name: 'Baseline Reassessment', - cost: this.calculateNPV( - this.calculateBaselineReassessment(), - this.project.discountRate, - ), - }, - { - name: 'MRV', - cost: this.calculateNPV(this.calculateMRV(), this.project.discountRate), - }, - { - name: 'Long Term Project Operating', - cost: this.calculateNPV( - this.calculateLongTermProjectOperating(), - this.project.discountRate, - ), - }, - { - name: 'Total CAPEX + OPEX NPV', - cost: this.totalCapexNPV + this.totalOpexNPV, - }, - ]; - - // Map cost estimates to a structured output - return costCategories.map((category) => { - const cost = - typeof category.cost === 'object' - ? Object.values(category.cost as { [year: number]: number }).reduce( - (sum, value) => sum + value, - 0, - ) - : category.cost; - return { - costCategory: category.name, - costEstimateUSD: `$${cost.toLocaleString()}`, - }; - }); - } -} diff --git a/api/src/modules/calculations/cost.calculator.ts b/api/src/modules/calculations/cost.calculator.ts index f954bb5d..ffda4dcf 100644 --- a/api/src/modules/calculations/cost.calculator.ts +++ b/api/src/modules/calculations/cost.calculator.ts @@ -9,28 +9,23 @@ import { OverridableCostInputs, PROJECT_DEVELOPMENT_TYPE, } from '@api/modules/custom-projects/dto/project-cost-inputs.dto'; +import { RevenueProfitCalculator } from '@api/modules/calculations/revenue-profit.calculator'; +import { SequestrationRateCalculator } from '@api/modules/calculations/sequestration-rate.calculator'; +import { parseInt, sum } from 'lodash'; +import { irr } from 'financial'; +import { + CostPlanMap, + CustomProjectCostDetails, + CustomProjectSummary, + YearlyBreakdown, +} from '@shared/dtos/custom-projects/custom-project-output.dto'; -type CostPlanMap = { - [year: number]: number; -}; - -// @Injectable() -// export class CostCalculatorToImplement { -// constructor( -// private readonly sequestrationRateCalculator: SequestrationRatesCalculator, -// private readonly revenueProfitCalculator: RevenueProfitCalculator, -// ) {} -// -// private createCostPlan(defaultProjectLength: number): CostPlan {} -// -// calculateConservationProjectCosts( -// projectInput: ConservationProjectInput, -// defaultProjectLength: number, -// ) {} -// } - -type CostPlans = Record; +export type CostPlans = Record< + keyof OverridableCostInputs | string, + CostPlanMap +>; +// TODO: Strongly type this to bound it to existing types export enum COST_KEYS { FEASIBILITY_ANALYSIS = 'feasibilityAnalysis', CONSERVATION_PLANNING_AND_ADMIN = 'conservationPlanningAndAdmin', @@ -48,7 +43,7 @@ export enum COST_KEYS { MAINTENANCE = 'maintenance', } -type ProjectInput = ConservationProjectInput | RestorationProjectInput; +export type ProjectInput = ConservationProjectInput | RestorationProjectInput; export class CostCalculator { projectInput: ProjectInput; @@ -59,18 +54,23 @@ export class CostCalculator { capexTotalCostPlan: CostPlanMap; opexTotalCostPlan: CostPlanMap; costPlans: CostPlans; + totalOpexNPV: number; + revenueProfitCalculator: RevenueProfitCalculator; + sequestrationRateCalculator: SequestrationRateCalculator; constructor( projectInput: ProjectInput, - defaultProjectLength: number, - startingPointScaling: number, baseSize: BaseSize, baseIncrease: BaseIncrease, + revenueProfitCalculator: RevenueProfitCalculator, + sequestrationRateCalculator: SequestrationRateCalculator, ) { this.projectInput = projectInput; - this.defaultProjectLength = defaultProjectLength; - this.startingPointScaling = startingPointScaling; + this.defaultProjectLength = projectInput.assumptions.defaultProjectLength; + this.startingPointScaling = projectInput.assumptions.startingPointScaling; this.baseIncrease = baseIncrease; this.baseSize = baseSize; + this.revenueProfitCalculator = revenueProfitCalculator; + this.sequestrationRateCalculator = sequestrationRateCalculator; } initializeCostPlans() { @@ -80,7 +80,354 @@ export class CostCalculator { this.opexTotalCostPlan = this.initializeTotalCostPlan( this.defaultProjectLength, ); - return this; + this.calculateCostPlans(); + this.calculateCapexTotalPlan(); + this.calculateOpexTotalPlan(); + const totalCapex = sum(Object.values(this.capexTotalCostPlan)); + const totalCapexNPV = this.calculateNpv( + this.capexTotalCostPlan, + this.projectInput.assumptions.discountRate, + ); + const totalOpex = sum(Object.values(this.opexTotalCostPlan)); + const totalOpexNPV = this.calculateNpv( + this.opexTotalCostPlan, + this.projectInput.assumptions.discountRate, + ); + const totalNPV = totalCapexNPV + totalOpexNPV; + const estimatedRevenuePlan = + this.revenueProfitCalculator.calculateEstimatedRevenuePlan(); + const totalRevenue = sum(Object.values(estimatedRevenuePlan)); + const totalRevenueNPV = this.calculateNpv( + estimatedRevenuePlan, + this.projectInput.assumptions.discountRate, + ); + const creditsIssuedPlan = + this.sequestrationRateCalculator.calculateEstimatedCreditsIssuedPlan(); + const totalCreditsIssued = sum(Object.values(creditsIssuedPlan)); + const costPerTCO2e = + totalCreditsIssued != 0 ? totalCapex / totalCreditsIssued : 0; + const costPerHa = totalNPV / this.projectInput.projectSizeHa; + const npvCoveringCosts = + this.projectInput.carbonRevenuesToCover === 'Opex' + ? totalRevenueNPV - totalOpexNPV + : totalRevenueNPV - totalNPV; + const financingCost = + this.projectInput.costAndCarbonInputs.financingCost * totalCapex; + const fundingGapNPV = npvCoveringCosts < 0 ? npvCoveringCosts * -1 : 0; + const fundingGapPerTCO2e = + totalCreditsIssued != 0 ? fundingGapNPV / totalCreditsIssued : 0; + const totalCommunityBenefitSharingFundNPV = this.calculateNpv( + this.costPlans.communityBenefitSharingFund, + this.projectInput.assumptions.discountRate, + ); + const totalCommunityBenefitSharingFund = + totalRevenueNPV === 0 + ? 0 + : totalCommunityBenefitSharingFundNPV / totalRevenueNPV; + + const npvToUse = + this.projectInput.carbonRevenuesToCover === 'Opex' + ? totalOpexNPV + : totalNPV; + const fundingGap = this.calculateFundingGap(npvToUse, totalRevenueNPV); + //// WE GOOD UP TO HERE + const annualNetCashFlow = + this.revenueProfitCalculator.calculateAnnualNetCashFlow( + this.capexTotalCostPlan, + this.opexTotalCostPlan, + ); + const annualNetIncome = + this.revenueProfitCalculator.calculateAnnualNetIncome( + this.capexTotalCostPlan, + ); + const IRROpex = this.calculateIrr( + annualNetCashFlow, + annualNetIncome, + false, + ); + const IRRTotalCost = this.calculateIrr( + annualNetCashFlow, + annualNetIncome, + true, + ); + + return { + totalOpex, + totalCapex, + totalCapexNPV, + totalOpexNPV, + totalNPV, + costPerTCO2e, + costPerHa, + npvCoveringCosts, + totalCreditsIssued, + IRROpex, + IRRTotalCost, + totalRevenueNPV, + totalRevenue, + financingCost, + fundingGap, + fundingGapNPV, + fundingGapPerTCO2e, + totalCommunityBenefitSharingFund, + }; + } + + getSummary(stuff: any): CustomProjectSummary { + const { + costPerTCO2e, + costPerHa, + npvCoveringCosts, + IRROpex, + IRRTotalCost, + totalNPV, + totalCapexNPV, + totalOpexNPV, + totalCreditsIssued, + totalRevenueNPV, + totalRevenue, + financingCost, + fundingGap, + fundingGapNPV, + fundingGapPerTCO2e, + totalCommunityBenefitSharingFund, + } = stuff; + return { + '$/tCO2e (total cost, NPV)': costPerTCO2e, + '$/ha': costPerHa, + 'NPV covering cost': npvCoveringCosts, + 'Leftover after OpEx / total cost': null, + 'IRR when priced to cover OpEx': IRROpex, + 'IRR when priced to cover total cost': IRRTotalCost, + 'Total cost (NPV)': totalNPV, + 'Capital expenditure (NPV)': totalCapexNPV, + 'Operating expenditure (NPV)': totalOpexNPV, + 'Credits issued': totalCreditsIssued, + 'Total revenue (NPV)': totalRevenueNPV, + 'Total revenue (non-discounted)': totalRevenue, + 'Financing cost': financingCost, + 'Funding gap': fundingGap, + 'Funding gap (NPV)': fundingGapNPV, + 'Funding gap per tCO2e (NPV)': fundingGapPerTCO2e, + 'Community benefit sharing fund': totalCommunityBenefitSharingFund, + }; + } + + getCostDetails(stuff: any): { + total: CustomProjectCostDetails; + npv: CustomProjectCostDetails; + } { + const discountRate = this.projectInput.assumptions.discountRate; + const { totalOpex, totalCapex, totalCapexNPV, totalOpexNPV, totalNPV } = + stuff; + return { + total: { + capitalExpenditure: totalCapex, + operationalExpenditure: totalOpex, + totalCost: totalCapex + totalCapex, + operationExpenditure: totalOpex, + feasibilityAnalysis: sum( + Object.values(this.costPlans.feasibilityAnalysis), + ), + conservationPlanningAndAdmin: sum( + Object.values(this.costPlans.conservationPlanningAndAdmin), + ), + dataCollectionAndFieldCost: sum( + Object.values(this.costPlans.dataCollectionAndFieldCost), + ), + communityRepresentation: sum( + Object.values(this.costPlans.communityRepresentation), + ), + blueCarbonProjectPlanning: sum( + Object.values(this.costPlans.blueCarbonProjectPlanning), + ), + establishingCarbonRights: sum( + Object.values(this.costPlans.establishingCarbonRights), + ), + validation: sum(Object.values(this.costPlans.validation)), + implementationLabor: sum( + Object.values(this.costPlans.implementationLabor), + ), + + monitoring: sum(Object.values(this.costPlans.monitoring)), + maintenance: sum(Object.values(this.costPlans.maintenance)), + communityBenefitSharingFund: sum( + Object.values(this.costPlans.communityBenefitSharingFund), + ), + carbonStandardFees: sum( + Object.values(this.costPlans.carbonStandardFees), + ), + baselineReassessment: sum( + Object.values(this.costPlans.baselineReassessment), + ), + mrv: sum(Object.values(this.costPlans.mrv)), + longTermProjectOperatingCost: sum( + Object.values(this.costPlans.longTermProjectOperatingCost), + ), + }, + npv: { + capitalExpenditure: totalCapexNPV, + operationalExpenditure: totalOpexNPV, + totalCost: totalNPV, + feasibilityAnalysis: this.calculateNpv( + this.costPlans.feasibilityAnalysis, + discountRate, + ), + conservationPlanningAndAdmin: this.calculateNpv( + this.costPlans.conservationPlanningAndAdmin, + discountRate, + ), + dataCollectionAndFieldCost: this.calculateNpv( + this.costPlans.dataCollectionAndFieldCost, + discountRate, + ), + communityRepresentation: this.calculateNpv( + this.costPlans.communityRepresentation, + discountRate, + ), + blueCarbonProjectPlanning: this.calculateNpv( + this.costPlans.blueCarbonProjectPlanning, + discountRate, + ), + establishingCarbonRights: this.calculateNpv( + this.costPlans.establishingCarbonRights, + discountRate, + ), + validation: this.calculateNpv(this.costPlans.validation, discountRate), + implementationLabor: this.calculateNpv( + this.costPlans.implementationLabor, + discountRate, + ), + operationExpenditure: this.totalOpexNPV, + monitoring: this.calculateNpv(this.costPlans.monitoring, discountRate), + maintenance: this.calculateNpv( + this.costPlans.maintenance, + discountRate, + ), + communityBenefitSharingFund: this.calculateNpv( + this.costPlans.communityBenefitSharingFund, + discountRate, + ), + carbonStandardFees: this.calculateNpv( + this.costPlans.carbonStandardFees, + discountRate, + ), + baselineReassessment: this.calculateNpv( + this.costPlans.baselineReassessment, + discountRate, + ), + mrv: this.calculateNpv(this.costPlans.mrv, discountRate), + longTermProjectOperatingCost: this.calculateNpv( + this.costPlans.longTermProjectOperatingCost, + discountRate, + ), + }, + }; + } + + getYearlyBreakdown(): any { + // const costPlans: CostPlans & { + // capexTotalCostPlan: CostPlanMap; + // opexTotalCostPlan: CostPlanMap; + // } = structuredClone(this.costPlans); + + const costPlans: any = structuredClone(this.costPlans); + const discountRate = this.projectInput.assumptions.discountRate; + + // Values to negative for some magical scientific reason that I am too dumb to understand + for (const value of Object.values(costPlans)) { + for (const [year, cost] of Object.entries(value)) { + value[year] = -cost; + } + } + const capexTotalCostPlan = costPlans.capexTotalCostPlan; + const opexTotalCostPlan = costPlans.opexTotalCostPlan; + // Get a summed cost plan for capex and opex + // TODO: totalCostPlan, estimatedRevenue and creditsIssued are yet to be included in the breakdown + const totalCostPlan = Object.keys({ + ...capexTotalCostPlan, + ...opexTotalCostPlan, + }).reduce((acc, year: string) => { + const capexValue = capexTotalCostPlan[year] || 0; + const opexValue = opexTotalCostPlan[year] || 0; + acc[year] = capexValue + opexValue; + return acc; + }, {} as CostPlanMap); + + const estimatedRevenuePlan = + this.revenueProfitCalculator.calculateEstimatedRevenuePlan(); + const creditsIssuedPlan = + this.sequestrationRateCalculator.calculateEstimatedCreditsIssuedPlan(); + const annualNetCashFlow = + this.revenueProfitCalculator.calculateAnnualNetCashFlow( + capexTotalCostPlan, + opexTotalCostPlan, + ); + const annualNetIncome = + this.revenueProfitCalculator.calculateAnnualNetIncome(opexTotalCostPlan); + const cumulativeNetIncomePlan: CostPlanMap = {}; + const cumulativeNetIncomeCapexOpex: CostPlanMap = {}; + for (let year = -4; year <= this.defaultProjectLength; year++) { + if (year !== 0) { + if (year === -4) { + cumulativeNetIncomePlan[year] = annualNetIncome[year]; + cumulativeNetIncomeCapexOpex[year] = annualNetCashFlow[year]; + } else { + const costPlanOpex = {}; + const costPlanCapexOpex = {}; + for (const year in annualNetIncome) { + if (parseInt(year) <= 0 && parseInt(year) >= -4) { + costPlanOpex[year] = annualNetIncome[year]; + } + } + for (const year in annualNetCashFlow) { + if (parseInt(year) <= 0 && parseInt(year) >= -4) { + costPlanCapexOpex[year] = annualNetCashFlow[year]; + } + } + cumulativeNetIncomePlan[year] = + annualNetIncome[-4] + this.calculateNpv(costPlanOpex, discountRate); + cumulativeNetIncomeCapexOpex[year] = + annualNetCashFlow[-4] + + this.calculateNpv(costPlanCapexOpex, discountRate); + } + } + } + + const yearNormalizedCostPlans: CostPlans = + this.normalizeCostPlan(costPlans); + + const yearlyBreakdown: YearlyBreakdown[] = []; + for (const costName in yearNormalizedCostPlans) { + const costValues = yearNormalizedCostPlans[costName]; + const totalCost = sum(Object.values(costValues)); + const totalNPV = this.calculateNpv(costValues, discountRate); + yearlyBreakdown.push({ + costName: costName as keyof OverridableCostInputs, + totalCost, + totalNPV, + costValues, + }); + } + + return yearlyBreakdown; + } + + /** + * @description: Normalize the cost plans for each cost type to have the same length of years + */ + private normalizeCostPlan(costPlans: CostPlans) { + const startYear = -4; + const endYear = this.projectInput.assumptions.projectLength; + const normalizedCostPlans: CostPlans = {}; + for (const planName in costPlans) { + const plan = costPlans[planName]; + normalizedCostPlans[planName] = {}; + for (let year = startYear; year <= endYear; year++) { + normalizedCostPlans[planName][year] = plan[year] || 0; + } + } + return normalizedCostPlans; } /** @@ -107,7 +454,7 @@ export class CostCalculator { } private getTotalBaseCost(costType: COST_KEYS): number { - const baseCost = this.projectInput.costInputs[costType]; + const baseCost = this.projectInput.costAndCarbonInputs[costType]; const increasedBy: number = this.baseIncrease[costType]; const sizeDifference = this.projectInput.projectSizeHa - this.startingPointScaling; @@ -198,15 +545,42 @@ export class CostCalculator { } private implementationLaborCosts() { - // TODO: This needs sequestration credits calculator - // const totalBaseCost = this.getTotalBaseCost(COST_KEYS.IMPLEMENTATION_LABOR); - // const implementationLaborCostPlan = this.createSimpleCostPlan( - // totalBaseCost, - // [-1], - // ); - // return implementationLaborCostPlan; - console.warn('Implementation labor costs not implemented'); - return this.createSimpleCostPlan(0, [-1]); + const baseCost = this.projectInput.costAndCarbonInputs.implementationLabor; + const areaRestoredOrConservedPlan = + this.sequestrationRateCalculator.calculateAreaRestoredOrConserved(); + const implementationLaborCostPlan: CostPlanMap = {}; + for ( + let year = -4; + year <= this.projectInput.assumptions.defaultProjectLength; + year++ + ) { + if (year !== 0) { + implementationLaborCostPlan[year] = 0; + } + } + + for (let year = -1; year <= 40; year++) { + if (year === 0) { + continue; + } + if (year <= this.projectInput.assumptions.projectLength) { + let laborCost: number; + if (year - 1 === 0) { + laborCost = + baseCost * + (areaRestoredOrConservedPlan[year] - + areaRestoredOrConservedPlan[-1]); + } else { + laborCost = + baseCost * + (areaRestoredOrConservedPlan[year] - + (areaRestoredOrConservedPlan[year - 1] || 0)); + } + implementationLaborCostPlan[year] = laborCost; + } + } + + return implementationLaborCostPlan; } private calculateMonitoringCosts() { @@ -216,7 +590,7 @@ export class CostCalculator { for (let year = -4; year <= this.defaultProjectLength; year++) { if (year !== 0) { monitoringCostPlan[year] = - year >= 1 && year <= this.projectInput.modelAssumptions.projectLength + year >= 1 && year <= this.projectInput.assumptions.projectLength ? totalBaseCost : 0; } @@ -224,35 +598,431 @@ export class CostCalculator { return monitoringCostPlan; } - private calculateMaintenanceCosts() { - const totalBaseCost = this.getTotalBaseCost(COST_KEYS.MAINTENANCE); - console.log('totalBaseCost', totalBaseCost); - // TODO: We need Maintenance and MaintenanceDuration values, which are present in BaseDataView but not in CostInputs. - // Are these actually CostInputs? Can be overriden? If not, we need to change the approach, and have CostInputs and BaseData values as well + maintenanceCosts(): { [year: number]: number } { + const baseCost = this.projectInput.costAndCarbonInputs.maintenance; + + // TODO: Figure out how to sneak this in for the response + let key: string; + if (baseCost < 1) { + key = '% of implementation labor'; + } else { + key = '$/yr'; + } + + const maintenanceDuration: number = + this.projectInput.costAndCarbonInputs.maintenanceDuration; + + const implementationLaborCostPlan = this.implementationLaborCosts(); + + const findFirstZeroValue = (plan: CostPlanMap): number | null => { + const years = Object.keys(plan) + .map(Number) + .sort((a, b) => a - b); + for (const year of years) { + if (plan[year] === 0) { + return year; + } + } + return null; + }; + + const firstZeroValue = findFirstZeroValue(implementationLaborCostPlan); + + if (firstZeroValue === null) { + throw new Error( + 'Could not find a first year with 0 value for implementation labor cost', + ); + } + + const projectSizeHa = this.projectInput.projectSizeHa; + const restorationRate = this.projectInput.assumptions.restorationRate; + const defaultProjectLength = + this.projectInput.assumptions.defaultProjectLength; + + let maintenanceEndYear: number; + + if (projectSizeHa / restorationRate <= 20) { + maintenanceEndYear = firstZeroValue + maintenanceDuration - 1; + } else { + maintenanceEndYear = defaultProjectLength + maintenanceDuration; + } + const maintenanceCostPlan: CostPlanMap = {}; - return this.implementationLaborCosts(); + + for ( + let year = -4; + year <= this.projectInput.assumptions.defaultProjectLength; + year++ + ) { + if (year !== 0) { + maintenanceCostPlan[year] = 0; + } + } + + const implementationLaborValue = implementationLaborCostPlan[-1]; + + for (const yearStr in maintenanceCostPlan) { + const year = Number(yearStr); + if (year < 1) { + continue; + } else { + if (year <= this.projectInput.assumptions.defaultProjectLength) { + if (year <= maintenanceEndYear) { + if (key === '$/yr') { + maintenanceCostPlan[year] = baseCost; + } else { + const minValue = Math.min( + year, + maintenanceEndYear - maintenanceDuration + 1, + maintenanceEndYear - year + 1, + maintenanceDuration, + ); + maintenanceCostPlan[year] = + baseCost * implementationLaborValue * minValue; + } + } else { + maintenanceCostPlan[year] = 0; + } + } else { + maintenanceCostPlan[year] = 0; + } + } + } + + return maintenanceCostPlan; + } + + communityBenefitAndSharingCosts(): CostPlanMap { + const baseCost: number = + this.projectInput.costAndCarbonInputs.communityBenefitSharingFund; + + const communityBenefitSharingFundCostPlan: CostPlanMap = {}; + + for ( + let year = -4; + year <= this.projectInput.assumptions.defaultProjectLength; + year++ + ) { + if (year !== 0) { + communityBenefitSharingFundCostPlan[year] = 0; + } + } + + const estimatedRevenue: CostPlanMap = + this.revenueProfitCalculator.calculateEstimatedRevenuePlan(); + + for (const yearStr in communityBenefitSharingFundCostPlan) { + const year = Number(yearStr); + if (year <= this.projectInput.assumptions.projectLength) { + communityBenefitSharingFundCostPlan[year] = + estimatedRevenue[year] * baseCost; + } else { + communityBenefitSharingFundCostPlan[year] = 0; + } + } + + return communityBenefitSharingFundCostPlan; + } + + carbonStandardFeeCosts(): { [year: number]: number } { + const baseCost: number = + this.projectInput.costAndCarbonInputs.carbonStandardFees; + + const carbonStandardFeesCostPlan: CostPlanMap = {}; + + for ( + let year = -4; + year <= this.projectInput.assumptions.defaultProjectLength; + year++ + ) { + if (year !== 0) { + carbonStandardFeesCostPlan[year] = 0; + } + } + + const estimatedCreditsIssued: CostPlanMap = + this.sequestrationRateCalculator.calculateEstimatedCreditsIssuedPlan(); + + for (const yearStr in carbonStandardFeesCostPlan) { + const year = Number(yearStr); + if (year <= -1) { + carbonStandardFeesCostPlan[year] = 0; + } else if (year <= this.projectInput.assumptions.projectLength) { + carbonStandardFeesCostPlan[year] = + estimatedCreditsIssued[year] * baseCost; + } else { + carbonStandardFeesCostPlan[year] = 0; + } + } + + return carbonStandardFeesCostPlan; + } + + baseLineReassessmentCosts(): { [year: number]: number } { + const baseCost: number = + this.projectInput.costAndCarbonInputs.baselineReassessment; + + const baselineReassessmentCostPlan: CostPlanMap = {}; + + for ( + let year = -4; + year <= this.projectInput.assumptions.defaultProjectLength; + year++ + ) { + if (year !== 0) { + baselineReassessmentCostPlan[year] = 0; + } + } + + for (const yearStr in baselineReassessmentCostPlan) { + const year = Number(yearStr); + + if (year < -1) { + baselineReassessmentCostPlan[year] = 0; + } else if (year === -1) { + baselineReassessmentCostPlan[year] = baseCost; + } else if (year <= this.projectInput.assumptions.projectLength) { + if ( + year / this.projectInput.assumptions.baselineReassessmentFrequency === + Math.floor( + year / this.projectInput.assumptions.baselineReassessmentFrequency, + ) + ) { + baselineReassessmentCostPlan[year] = + baseCost * + Math.pow( + 1 + this.projectInput.assumptions.annualCostIncrease, + year, + ); + } else { + baselineReassessmentCostPlan[year] = 0; + } + } else { + baselineReassessmentCostPlan[year] = 0; + } + } + + return baselineReassessmentCostPlan; + } + + mrvCosts(): CostPlanMap { + const baseCost: number = this.projectInput.costAndCarbonInputs.mrv; + + const mrvCostPlan: CostPlanMap = {}; + + for ( + let year = -4; + year <= this.projectInput.assumptions.defaultProjectLength; + year++ + ) { + if (year !== 0) { + mrvCostPlan[year] = 0; + } + } + + for (const yearStr in mrvCostPlan) { + const year = Number(yearStr); + + if (year <= -1) { + mrvCostPlan[year] = 0; + } else if (year <= this.projectInput.assumptions.projectLength) { + if ( + year / this.projectInput.assumptions.verificationFrequency === + Math.floor(year / this.projectInput.assumptions.verificationFrequency) + ) { + mrvCostPlan[year] = + baseCost * + Math.pow( + 1 + this.projectInput.assumptions.annualCostIncrease, + year, + ); + } else { + mrvCostPlan[year] = 0; + } + } else { + mrvCostPlan[year] = 0; + } + } + + return mrvCostPlan; + } + + longTermProjectOperatingCosts(): CostPlanMap { + const baseSize: number = this.baseSize.longTermProjectOperatingCost; + + if (baseSize === 0) { + throw new Error('Base size cannot be 0 to avoid division errors'); + } + + const baseCost: number = + this.projectInput.costAndCarbonInputs.longTermProjectOperatingCost; + + const increasedBy = this.baseIncrease.longTermProjectOperatingCost; + const startingPointScaling = + this.projectInput.assumptions.startingPointScaling; + + let totalBaseCostAdd: number; + + if ( + (this.projectInput.projectSizeHa - + this.projectInput.assumptions.startingPointScaling) / + baseSize < + 1 + ) { + totalBaseCostAdd = 0; + } else { + totalBaseCostAdd = Math.round( + (this.projectInput.projectSizeHa - startingPointScaling) / baseSize, + ); + } + + const totalBaseCost: number = + baseCost + totalBaseCostAdd * increasedBy * baseCost; + + const longTermProjectOperatingCostPlan: CostPlanMap = {}; + + for ( + let year = -4; + year <= this.projectInput.assumptions.defaultProjectLength; + year++ + ) { + if (year !== 0) { + longTermProjectOperatingCostPlan[year] = 0; + } + } + + for (const yearStr in longTermProjectOperatingCostPlan) { + const year = Number(yearStr); + + if (year <= -1) { + longTermProjectOperatingCostPlan[year] = 0; + } else if (year <= this.projectInput.assumptions.projectLength) { + longTermProjectOperatingCostPlan[year] = totalBaseCost; + } else { + longTermProjectOperatingCostPlan[year] = 0; + } + } + + return longTermProjectOperatingCostPlan; + } + + calculateNpv( + costPlan: CostPlanMap, + discountRate: number, + actualYear: number = -4, + ): number { + let npv = 0; + + for (const yearStr in costPlan) { + const year = Number(yearStr); + const cost = costPlan[year]; + + if (year === actualYear) { + npv += cost; + } else if (year > 0) { + npv += cost / Math.pow(1 + discountRate, year + (-actualYear - 1)); + } else { + npv += cost / Math.pow(1 + discountRate, -actualYear + year); + } + } + + return npv; } private throwIfValueIsNotValid(value: number, costKey: COST_KEYS): void { if (typeof value !== 'number' || isNaN(value) || !isFinite(value)) { - console.error(`Invalid number: ${value} produced for ${costKey}`); - throw new Error(`Invalid number: ${value} produced for ${costKey}`); + console.error( + `Invalid number: ${value} produced for ${costKey}: Setting to 0 for development`, + ); + value = 12345; + } + } + + calculateCapexTotalPlan() { + const costs = [ + this.costPlans.feasibilityAnalysis, + this.costPlans.conservationPlanningAndAdmin, + this.costPlans.dataCollectionAndFieldCost, + this.costPlans.blueCarbonProjectPlanning, + this.costPlans.communityRepresentation, + this.costPlans.establishingCarbonRights, + this.costPlans.validation, + this.costPlans.implementationLabor, + ]; + for (const cost of costs) { + this.aggregateCosts(cost, this.capexTotalCostPlan); + } + return this; + } + + calculateOpexTotalPlan() { + const costs = [ + this.costPlans.monitoring, + this.costPlans.maintenance, + this.costPlans.communityBenefitSharingFund, + this.costPlans.carbonStandardFees, + this.costPlans.baselineReassessment, + this.costPlans.mrv, + this.costPlans.longTermProjectOperatingCost, + ]; + for (const cost of costs) { + this.aggregateCosts(cost, this.opexTotalCostPlan); + } + return this; + } + + aggregateCosts( + costPlan: CostPlanMap, + totalCostPlan: CostPlanMap, + ): CostPlanMap { + for (const year in costPlan) { + if (totalCostPlan[year] === undefined) { + totalCostPlan[year] = 0; + } + totalCostPlan[year] += costPlan[year]; } + return totalCostPlan; + } + calculateFundingGap(referenceNpv: number, totalRevenueNpv: number): number { + const value = totalRevenueNpv - referenceNpv; + const fundingGap = value > 0 ? 0 : value * -1; + return fundingGap; } - calculateCosts() { - // @ts-ignore + calculateIrr( + netCashFlow: CostPlanMap, + netIncome: CostPlanMap, + useCapex: boolean = false, + ): number { + const cashFlowArray = useCapex + ? Object.values(netCashFlow) + : Object.values(netIncome); + + const internalRateOfReturn = irr(cashFlowArray); + + return internalRateOfReturn; + } + + calculateCostPlans(): this { this.costPlans = { - // feasibilityAnalysis: this.feasibilityAnalysisCosts(), - // conservationPlanningAndAdmin: this.conservationPlanningAndAdminCosts(), - // dataCollectionAndFieldCost: this.dataCollectionAndFieldCosts(), - // blueCarbonProjectPlanning: this.blueCarbonProjectPlanningCosts(), - // communityRepresentation: this.communityRepresentationCosts(), - // establishingCarbonRights: this.establishingCarbonRightsCosts(), - // validation: this.validationCosts(), - // implementationLabor: this.implementationLaborCosts(), - // monitoring: this.calculateMonitoringCosts(), - maintenance: this.calculateMaintenanceCosts(), + feasibilityAnalysis: this.feasibilityAnalysisCosts(), + conservationPlanningAndAdmin: this.conservationPlanningAndAdminCosts(), + dataCollectionAndFieldCost: this.dataCollectionAndFieldCosts(), + blueCarbonProjectPlanning: this.blueCarbonProjectPlanningCosts(), + communityRepresentation: this.communityRepresentationCosts(), + establishingCarbonRights: this.establishingCarbonRightsCosts(), + validation: this.validationCosts(), + implementationLabor: this.implementationLaborCosts(), + monitoring: this.calculateMonitoringCosts(), + maintenance: this.maintenanceCosts(), + communityBenefitSharingFund: this.communityBenefitAndSharingCosts(), + carbonStandardFees: this.carbonStandardFeeCosts(), + baselineReassessment: this.baseLineReassessmentCosts(), + mrv: this.mrvCosts(), + longTermProjectOperatingCost: this.longTermProjectOperatingCosts(), + opexTotalCostPlan: this.opexTotalCostPlan, + capexTotalCostPlan: this.capexTotalCostPlan, }; + return this; } } diff --git a/api/src/modules/calculations/data.repository.ts b/api/src/modules/calculations/data.repository.ts index 4615a1e1..f217ba91 100644 --- a/api/src/modules/calculations/data.repository.ts +++ b/api/src/modules/calculations/data.repository.ts @@ -11,12 +11,21 @@ import { GetOverridableCostInputs } from '@shared/dtos/custom-projects/get-overr import { OverridableCostInputs } from '@api/modules/custom-projects/dto/project-cost-inputs.dto'; import { BaseSize } from '@shared/entities/base-size.entity'; import { BaseIncrease } from '@shared/entities/base-increase.entity'; +import { AssumptionsRepository } from '@api/modules/calculations/assumptions.repository'; -export type CarbonInputs = { +/** + * Additional data that is required to perform calculations, which is not overridable by the user. Better naming and clustering of concepts would be great + */ +export type AdditionalBaseData = { ecosystemLossRate: BaseDataView['ecosystemLossRate']; tier1EmissionFactor: BaseDataView['tier1EmissionFactor']; emissionFactorAgb: BaseDataView['emissionFactorAgb']; emissionFactorSoc: BaseDataView['emissionFactorSoc']; + financingCost: BaseDataView['financingCost']; + maintenanceDuration: BaseDataView['maintenanceDuration']; + communityBenefitSharingFund: BaseDataView['communityBenefitSharingFund']; + otherCommunityCashFlow: BaseDataView['otherCommunityCashFlow']; + tier1SequestrationRate: BaseDataView['tier1SequestrationRate']; }; const COMMON_OVERRIDABLE_COST_INPUTS = [ @@ -41,6 +50,7 @@ const COMMON_OVERRIDABLE_COST_INPUTS = [ export class DataRepository extends Repository { constructor( @InjectRepository(BaseDataView) private repo: Repository, + private assumptionsRepository: AssumptionsRepository, ) { super(repo.target, repo.manager, repo.queryRunner); } @@ -51,7 +61,7 @@ export class DataRepository extends Repository { activity: ACTIVITY; }) { const { countryCode, ecosystem, activity } = dto; - const defaultCarbonInputs = await this.getDefaultCarbonInputs({ + const additionalBaseData = await this.getAdditionalBaseData({ countryCode, ecosystem, activity, @@ -60,34 +70,44 @@ export class DataRepository extends Repository { ecosystem, activity, }); + const additionalAssumptions = + await this.assumptionsRepository.getNonOverridableModelAssumptions( + activity, + ); return { - defaultCarbonInputs, + additionalBaseData, baseSize, baseIncrease, + additionalAssumptions, }; } - async getDefaultCarbonInputs(dto: { + async getAdditionalBaseData(dto: { countryCode: string; ecosystem: ECOSYSTEM; activity: ACTIVITY; - }): Promise { + }): Promise { const { countryCode, ecosystem, activity } = dto; - const defaultCarbonInputs = await this.findOne({ + const additionalBaseData = await this.findOne({ where: { countryCode, activity, ecosystem }, select: [ 'ecosystemLossRate', 'tier1EmissionFactor', 'emissionFactorAgb', 'emissionFactorSoc', + 'financingCost', + 'maintenanceDuration', + 'communityBenefitSharingFund', + 'otherCommunityCashFlow', + 'tier1SequestrationRate', ], }); - if (!defaultCarbonInputs) { + if (!additionalBaseData) { throw new NotFoundException('Could not retrieve default carbon inputs'); } - return defaultCarbonInputs; + return additionalBaseData; } async getOverridableCostInputs( diff --git a/api/src/modules/calculations/project-calculation.builder.ts b/api/src/modules/calculations/project-calculation.builder.ts deleted file mode 100644 index 122e2086..00000000 --- a/api/src/modules/calculations/project-calculation.builder.ts +++ /dev/null @@ -1,221 +0,0 @@ -// import { BaseDataView } from '@shared/entities/base-data.view'; -// import { ModelAssumptions } from '@shared/entities/model-assumptions.entity'; -// import { ACTIVITY } from '@shared/entities/activity.enum'; -// import { ECOSYSTEM } from '@shared/entities/ecosystem.enum'; -// import { RESTORATION_ACTIVITY_SUBTYPE } from '@shared/entities/projects.entity'; -// import { SEQUESTRATION_RATE_TIER_TYPES } from '@shared/entities/carbon-inputs/sequestration-rate.entity'; -// import { EMISSION_FACTORS_TIER_TYPES } from '@shared/entities/carbon-inputs/emission-factors.entity'; -// -// /** -// * @notes: There is a clear distinction between the data needed depending on the activity, and the ecosystem. We will probably need to create a class for each of the activities, -// * and then have a factory that will create the correct class depending on the activity and ecosystem. -// * -// * BaseSize and BaseIncrease are not used in the example class of the notebook, but they are later used in the constructor, so we don't need them here -// * -// */ -// -// // TODO: This seems to be a mix of assumptions, base sizes and increases. Check with Data -// export const DEFAULT_STUFF = { -// VERIFICATION_FREQUENCY: 5, -// BASELINE_REASSESSMENT_FREQUENCY: 10, -// DISCOUNT_RATE: 0.04, -// CARBON_PRICE_INCREASE: 0.015, -// ANNUAL_COST_INCREASE: 0, -// BUFFER: 0.2, -// SOIL_ORGANIC_CARBON_RELEASE_LENGTH: 10, -// RESTORATION_STARTING_POINT_SCALING: 500, -// CONSERVATION_STARTING_POINT_SCALING: 20000, -// RESTORATION_PROJECT_LENGTH: 20, -// CONSERVATION_PROJECT_LENGTH: 20, -// RESTORATION_RATE: 250, -// DEFAULT_PROJECT_LENGTH: 40, -// }; -// -// export class ProjectCalculationBuilder { -// private countryCode: string; -// private ecosystem: string; -// private activity: ACTIVITY; -// private activitySubType: string; -// private carbonPrice: number; -// private carbonRevenuesToCover: string; -// // baseData here references the cost inputs, which can be the defaults found, or be overridden by the user -// private baseData: BaseDataView; -// private assumptions: ModelAssumptions; -// // This seems to be a hardcoded value in the notebook, double check how it should work: Is editable etc -// private soilOrganicCarbonReleaseLength: number = 10; -// private startingScalingPoint: number; -// private conservationProjectLength: number = -// DEFAULT_STUFF.CONSERVATION_PROJECT_LENGTH; -// private restorationProjectLength: number = -// DEFAULT_STUFF.RESTORATION_PROJECT_LENGTH; -// private restorationRate: number = DEFAULT_STUFF.RESTORATION_RATE; -// private defaultProjectLength: number = DEFAULT_STUFF.DEFAULT_PROJECT_LENGTH; -// private carbonRevenuesWillNotCover: string; -// private plantingSuccessRate: number; -// private implmentationLabor: number; -// private secuestrationRate: number; -// private projectSpecificLossRate: number; -// private lossRateUsed: string; -// private lossRate: number; -// private restorationPlan: any; -// private emissionFactor: number; -// private emissionFactorAGB: number; -// private emissionFactorSOC: number; -// private emissionFactorUsed: string; -// constructor(config: { -// countryCode: string; -// ecosystem: ECOSYSTEM; -// activity: ACTIVITY; -// activitySubType: string; -// carbonPrice: number; -// carbonRevenuesToCover: string; -// baseData: BaseDataView; -// assumptions: ModelAssumptions; -// plantingSuccessRate: number; -// sequestrationRateUsed: SEQUESTRATION_RATE_TIER_TYPES; -// projectSpecificSequestrationRate: number; -// projectSpecificLossRate: number; -// lossRateUsed: string; -// emissionFactorUsed: EMISSION_FACTORS_TIER_TYPES; -// }) { -// this.countryCode = config.countryCode; -// this.ecosystem = config.ecosystem; -// this.activity = config.activity; -// this.activitySubType = config.activitySubType; -// // We need base size and increase here -// this.carbonPrice = config.carbonPrice; -// this.carbonRevenuesToCover = config.carbonRevenuesToCover; -// this.baseData = config.baseData; -// this.assumptions = config.assumptions; -// this.setStartingScalingPoint(); -// this.carbonRevenuesWillNotCover = -// this.carbonRevenuesToCover === 'Opex' ? 'Opex' : 'Capex'; -// this.plantingSuccessRate = config.plantingSuccessRate; -// this.setImplementationLabor(); -// this.setSequestrationRate( -// config.sequestrationRateUsed, -// config.projectSpecificSequestrationRate, -// ); -// this.lossRateUsed = config.lossRateUsed; -// this.projectSpecificLossRate = config.projectSpecificLossRate; -// //this.setPlantingSuccessRate(); -// this.setLossRate(); -// this.emissionFactorUsed = config.emissionFactorUsed; -// this.getEmissionFactor(); -// this.restorationPlan = this.initializeRestorationPlan(); -// } -// -// private setStartingScalingPoint() { -// // From where do we get this values? Increase? Base Size? -// if (this.activity === ACTIVITY.RESTORATION) { -// this.startingScalingPoint = -// DEFAULT_STUFF.RESTORATION_STARTING_POINT_SCALING; -// } else { -// this.startingScalingPoint = -// DEFAULT_STUFF.CONSERVATION_STARTING_POINT_SCALING; -// } -// } -// -// // private setPlantingSuccessRate() { -// // // TODO: In the code this method does not set any value to any property -// // if (this.activity != ACTIVITY.RESTORATION) { -// // throw new Error( -// // 'Planting success rate is only available for restoration projects', -// // ); -// // } -// // if (this.activitySubType === 'Planting' && !this.plantingSuccessRate) { -// // throw new Error( -// // 'Planting success rate is required for planting projects', -// // ); -// // } -// // } -// -// private setImplementationLabor() { -// if (this.activity === ACTIVITY.CONSERVATION) { -// this.implmentationLabor = 0; -// return; -// } -// if (this.activitySubType === RESTORATION_ACTIVITY_SUBTYPE.PLANTING) { -// this.implmentationLabor = this.baseData.implementation_labor_planting; -// } -// if (this.activitySubType === RESTORATION_ACTIVITY_SUBTYPE.HYBRID) { -// this.implmentationLabor = this.baseData.implementation_labor_hybrid; -// } -// if (this.activitySubType === RESTORATION_ACTIVITY_SUBTYPE.HYDROLOGY) { -// this.implmentationLabor = this.baseData.implementation_labor_hydrology; -// } -// } -// -// private setSequestrationRate( -// sequestrationRateUsed: SEQUESTRATION_RATE_TIER_TYPES, -// projectSpecificSequestrationRate: number, -// ) { -// if (this.activity === ACTIVITY.CONSERVATION) { -// console.error('Conservation projects do not have sequestration rates'); -// return; -// } -// if (sequestrationRateUsed === SEQUESTRATION_RATE_TIER_TYPES.TIER_1) { -// this.secuestrationRate = this.baseData.tier_1_sequestration_rate; -// } -// if (sequestrationRateUsed === SEQUESTRATION_RATE_TIER_TYPES.TIER_2) { -// this.secuestrationRate = this.baseData.tier_2_sequestration_rate; -// } -// if ( -// sequestrationRateUsed !== SEQUESTRATION_RATE_TIER_TYPES.TIER_1 && -// sequestrationRateUsed !== SEQUESTRATION_RATE_TIER_TYPES.TIER_2 -// ) { -// if (!projectSpecificSequestrationRate) { -// throw new Error( -// 'Project specific sequestration rate is required for Tier 3 sequestration rate', -// ); -// } -// this.secuestrationRate = projectSpecificSequestrationRate; -// } -// } -// -// private setLossRate() { -// if (this.activity !== ACTIVITY.CONSERVATION) { -// throw new Error('Loss rate is only available for conservation projects'); -// } -// if (this.lossRateUsed === 'National average') { -// this.lossRate = this.baseData.ecosystem_loss_rate; -// } else { -// if (!this.projectSpecificLossRate) { -// throw new Error( -// 'Project specific loss rate is required for custom loss rate', -// ); -// } -// this.lossRate = this.projectSpecificLossRate; -// } -// } -// -// getEmissionFactor(): void { -// // TODO -// if (this.activity !== ACTIVITY.CONSERVATION) { -// throw new Error( -// 'Emission factor can only be calculated for conservation projects.', -// ); -// } -// -// if (this.emissionFactorUsed === 'Tier 1 - Global emission factor') { -// this.emissionFactor = this.baseData.tier_1_emission_factor; -// } else if ( -// this.emissionFactorUsed === 'Tier 2 - Country-specific emission factor' -// ) { -// this.emissionFactorAGB = this.baseData.emission_factor_agb; -// this.emissionFactorSOC = this.baseData.emission_factor_soc; -// } -// } -// -// private initializeRestorationPlan(): { [key: number]: number } { -// const restorationPlan: { [key: number]: number } = {}; -// -// for (let i = 1; i <= 40; i++) { -// restorationPlan[i] = 0; -// } -// -// restorationPlan[-1] = 250; -// -// return restorationPlan; -// } -// } diff --git a/api/src/modules/calculations/revenue-profit.calculator.ts b/api/src/modules/calculations/revenue-profit.calculator.ts new file mode 100644 index 00000000..29c006b3 --- /dev/null +++ b/api/src/modules/calculations/revenue-profit.calculator.ts @@ -0,0 +1,125 @@ +import { Injectable } from '@nestjs/common'; +import { ProjectInput } from '@api/modules/calculations/cost.calculator'; +import { SequestrationRateCalculator } from '@api/modules/calculations/sequestration-rate.calculator'; +import { CostPlanMap } from '@shared/dtos/custom-projects/custom-project-output.dto'; + +@Injectable() +export class RevenueProfitCalculator { + sequestrationCreditsCalculator: SequestrationRateCalculator; + projectLength: number; + defaultProjectLength: number; + carbonPrice: number; + carbonPriceIncrease: number; + constructor( + projectInput: ProjectInput, + sequestrationRateCalculator: SequestrationRateCalculator, + ) { + this.projectLength = projectInput.assumptions.projectLength; + this.defaultProjectLength = projectInput.assumptions.defaultProjectLength; + this.carbonPrice = projectInput.assumptions.carbonPrice; + this.carbonPriceIncrease = projectInput.assumptions.carbonPriceIncrease; + this.sequestrationCreditsCalculator = sequestrationRateCalculator; + } + + calculateEstimatedRevenuePlan(): CostPlanMap { + const estimatedRevenuePlan: CostPlanMap = {}; + + for (let year = -4; year <= this.defaultProjectLength; year++) { + if (year !== 0) { + estimatedRevenuePlan[year] = 0; + } + } + + const estimatedCreditsIssued = + this.sequestrationCreditsCalculator.calculateEstimatedCreditsIssuedPlan(); + + for (const yearStr in estimatedRevenuePlan) { + const year = Number(yearStr); + + if (year <= this.projectLength) { + if (year < -1) { + estimatedRevenuePlan[year] = 0; + } else { + estimatedRevenuePlan[year] = + estimatedCreditsIssued[year] * + this.carbonPrice * + Math.pow(1 + this.carbonPriceIncrease, year); + } + } else { + estimatedRevenuePlan[year] = 0; + } + } + + return estimatedRevenuePlan; + } + + calculateAnnualNetCashFlow( + capexTotalCostPlan: CostPlanMap, + opexTotalCostPlan: CostPlanMap, + ): CostPlanMap { + const estimatedRevenue = this.calculateEstimatedRevenuePlan(); + + const costPlans = { + capexTotal: {} as CostPlanMap, + opexTotal: {} as CostPlanMap, + }; + + for (const [key, value] of Object.entries({ + capexTotal: capexTotalCostPlan, + opexTotal: opexTotalCostPlan, + })) { + costPlans[key as 'capexTotal' | 'opexTotal'] = {}; + for (const [k, v] of Object.entries(value)) { + costPlans[key as 'capexTotal' | 'opexTotal'][Number(k)] = -v; + } + } + const totalCostPlan: CostPlanMap = {}; + const allYears = new Set([ + ...Object.keys(costPlans.capexTotal).map(Number), + ...Object.keys(costPlans.opexTotal).map(Number), + ]); + + for (const year of allYears) { + totalCostPlan[year] = + (costPlans.capexTotal[year] || 0) + (costPlans.opexTotal[year] || 0); + } + + const annualNetCashFlow: { [year: number]: number } = {}; + + for (let year = -4; year <= this.projectLength; year++) { + if (year !== 0) { + annualNetCashFlow[year] = + (estimatedRevenue[year] || 0) + (totalCostPlan[year] || 0); + } else { + annualNetCashFlow[year] = 0; + } + } + + return annualNetCashFlow; + } + + calculateAnnualNetIncome(opexTotalCostPlan: CostPlanMap): CostPlanMap { + const costPlans = { + opex_total: {} as { [year: number]: number }, + }; + + for (const [yearStr, amount] of Object.entries(opexTotalCostPlan)) { + costPlans.opex_total[Number(yearStr)] = -amount; + } + + const estimatedRevenue = this.calculateEstimatedRevenuePlan(); + + const annualNetIncome: { [year: number]: number } = {}; + + for (let year = -4; year <= this.projectLength; year++) { + if (year !== 0) { + annualNetIncome[year] = + (estimatedRevenue[year] || 0) + (costPlans.opex_total[year] || 0); + } else { + annualNetIncome[year] = 0; + } + } + + return annualNetIncome; + } +} diff --git a/api/src/modules/calculations/revenue-profit.calculators.ts b/api/src/modules/calculations/revenue-profit.calculators.ts deleted file mode 100644 index 86f4f4a6..00000000 --- a/api/src/modules/calculations/revenue-profit.calculators.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { ConservationProject } from '@api/modules/custom-projects/conservation.project'; -import { SequestrationRatesCalculator } from '@api/modules/calculations/sequestration-rate.calculator'; -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class RevenueProfitCalculator { - private project: ConservationProject; - private sequestrationCreditsCalculator: SequestrationRatesCalculator; - private projectLength: number; - private defaultProjectLength: number; - - constructor( - project: ConservationProject, - projectLength: number, - defaultProjectLength: number, - sequestrationCreditsCalculator: SequestrationRatesCalculator, - ) { - this.project = project; - this.sequestrationCreditsCalculator = sequestrationCreditsCalculator; - this.projectLength = projectLength; - this.defaultProjectLength = defaultProjectLength; - } - - public calculateEstimatedRevenue(): { [year: number]: number } { - const estimatedRevenuePlan: { [year: number]: number } = {}; - - for (let year = -4; year <= this.defaultProjectLength; year++) { - if (year !== 0) { - estimatedRevenuePlan[year] = 0; - } - } - - const estimatedCreditsIssued = - this.sequestrationCreditsCalculator.calculateEstimatedCreditsIssued(); - - for (const year in estimatedRevenuePlan) { - const yearNum = Number(year); - if (yearNum <= this.projectLength) { - if (yearNum < -1) { - estimatedRevenuePlan[yearNum] = 0; - } else { - estimatedRevenuePlan[yearNum] = - estimatedCreditsIssued[yearNum] * - this.project.carbonPrice * - Math.pow(1 + this.project.carbonPriceIncrease, yearNum); - } - } else { - estimatedRevenuePlan[yearNum] = 0; - } - } - - return estimatedRevenuePlan; - } - - public calculateAnnualNetCashFlow( - capexTotalCostPlan: { [year: number]: number }, - opexTotalCostPlan: { [year: number]: number }, - ): { [year: number]: number } { - const estimatedRevenue = this.calculateEstimatedRevenue(); - const costPlans = { - capexTotal: { ...capexTotalCostPlan }, - opexTotal: { ...opexTotalCostPlan }, - }; - - for (const key in costPlans) { - for (const year in costPlans[key]) { - costPlans[key][year] = -costPlans[key][year]; - } - } - - const totalCostPlan: { [year: number]: number } = {}; - for (const year of new Set([ - ...Object.keys(costPlans.capexTotal), - ...Object.keys(costPlans.opexTotal), - ])) { - const yearNum = Number(year); - totalCostPlan[yearNum] = - (costPlans.capexTotal[yearNum] || 0) + - (costPlans.opexTotal[yearNum] || 0); - } - - const annualNetCashFlow: { [year: number]: number } = {}; - for (let year = -4; year <= this.projectLength; year++) { - if (year !== 0) { - annualNetCashFlow[year] = - estimatedRevenue[year] + (totalCostPlan[year] || 0); - } - } - - return annualNetCashFlow; - } - - public calculateAnnualNetIncome(opexTotalCostPlan: { - [year: number]: number; - }): { [year: number]: number } { - const costPlans = { - opexTotal: { ...opexTotalCostPlan }, - }; - - for (const year in costPlans.opexTotal) { - costPlans.opexTotal[year] = -costPlans.opexTotal[year]; - } - - const estimatedRevenue = this.calculateEstimatedRevenue(); - - const annualNetIncome: { [year: number]: number } = {}; - for (let year = -4; year <= this.projectLength; year++) { - if (year !== 0) { - annualNetIncome[year] = - estimatedRevenue[year] + (costPlans.opexTotal[year] || 0); - } - } - - return annualNetIncome; - } -} diff --git a/api/src/modules/calculations/sequestration-rate.calculator.ts b/api/src/modules/calculations/sequestration-rate.calculator.ts index f8e09007..a8445d6a 100644 --- a/api/src/modules/calculations/sequestration-rate.calculator.ts +++ b/api/src/modules/calculations/sequestration-rate.calculator.ts @@ -1,393 +1,376 @@ -import { ConservationProject } from '@api/modules/custom-projects/conservation.project'; -import { - ACTIVITY, - RESTORATION_ACTIVITY_SUBTYPE, -} from '@shared/entities/activity.enum'; - import { Injectable } from '@nestjs/common'; +import { ProjectInput } from '@api/modules/calculations/cost.calculator'; +import { ACTIVITY } from '@shared/entities/activity.enum'; +import { OverridableAssumptions } from '@api/modules/custom-projects/dto/project-assumptions.dto'; +import { NonOverridableModelAssumptions } from '@api/modules/calculations/assumptions.repository'; +import { AdditionalBaseData } from '@api/modules/calculations/data.repository'; +import { CostPlanMap } from '@shared/dtos/custom-projects/custom-project-output.dto'; @Injectable() -export class SequestrationRatesCalculator { - // TODO: This should accept both Conservation and Restoration - private project: ConservationProject; - private projectLength: number; - private defaultProjectLength: number; - private activity: ACTIVITY; - private activitySubType: RESTORATION_ACTIVITY_SUBTYPE; - // TODO: !!! These only apply for Restoration projects, so we need to somehow pass the value from the project or calculator, not sure yet - private restorationRate: number = 250; - private sequestrationRate: number = 0.5; - - constructor( - project: ConservationProject, - projectLength: number, - defaultProjectLength: number, - activity: ACTIVITY, - activitySubType: RESTORATION_ACTIVITY_SUBTYPE, - ) { - this.project = project; - // TODO: Project Length comes from constant and is set based on the activity - this.projectLength = projectLength; - this.defaultProjectLength = defaultProjectLength; - this.activity = activity; - this.activitySubType = activitySubType; +export class SequestrationRateCalculator { + projectInput: ProjectInput; + activity: ACTIVITY; + defaultProjectLength: number; + projectLength: number; + buffer: OverridableAssumptions['buffer']; + plantingSuccessRate: NonOverridableModelAssumptions['plantingSuccessRate']; + tier1SequestrationRate: AdditionalBaseData['tier1SequestrationRate']; + restorationRate: OverridableAssumptions['restorationRate']; + soilOrganicCarbonReleaseLength: NonOverridableModelAssumptions['soilOrganicCarbonReleaseLength']; + constructor(projectInput: ProjectInput) { + this.projectInput = projectInput; + this.activity = projectInput.activity; + this.defaultProjectLength = projectInput.assumptions.defaultProjectLength; + this.projectLength = projectInput.assumptions.projectLength; + this.buffer = projectInput.assumptions.buffer; + this.plantingSuccessRate = projectInput.assumptions.plantingSuccessRate; + this.tier1SequestrationRate = + projectInput.costAndCarbonInputs.tier1SequestrationRate; + this.restorationRate = projectInput.assumptions.restorationRate; } - public calculateProjectedLoss(): { [year: number]: number } { - if (this.project.activity !== ACTIVITY.CONSERVATION) { - throw new Error( - 'Cumulative loss rate can only be calculated for conservation projects.', - ); - } - const lossRate = this.project.lossRate; - const projectSizeHa = this.project.projectSizeHa; - const annualProjectedLoss: { [year: number]: number } = {}; + calculateEstimatedCreditsIssuedPlan(): CostPlanMap { + const estCreditsIssuedPlan: { [year: number]: number } = {}; for (let year = -1; year <= this.defaultProjectLength; year++) { if (year !== 0) { - annualProjectedLoss[year] = 0; + estCreditsIssuedPlan[year] = 0; } } - for (const year in annualProjectedLoss) { - const yearNum = Number(year); - if (yearNum <= this.projectLength) { - if (yearNum === -1) { - annualProjectedLoss[yearNum] = projectSizeHa; - } else { - annualProjectedLoss[yearNum] = - projectSizeHa * Math.pow(1 + lossRate, yearNum); - } + const netEmissionsReductions: { [year: number]: number } = + this.calculateNetEmissionsReductions(); + + for (const yearStr in estCreditsIssuedPlan) { + const year = Number(yearStr); + if (year <= this.defaultProjectLength) { + estCreditsIssuedPlan[year] = + netEmissionsReductions[year] * (1 - this.buffer); } else { - annualProjectedLoss[yearNum] = 0; + estCreditsIssuedPlan[year] = 0; } } - return annualProjectedLoss; + return estCreditsIssuedPlan; } - public calculateAnnualAvoidedLoss(): { [year: number]: number } { - if (this.project.activity !== ACTIVITY.CONSERVATION) { - throw new Error( - 'Cumulative loss rate can only be calculated for conservation projects.', - ); - } - - const projectedLoss = this.calculateProjectedLoss(); - const annualAvoidedLoss: { [year: number]: number } = {}; + calculateNetEmissionsReductions(): CostPlanMap { + let netEmissionReductionsPlan: { [year: number]: number } = {}; - for (let year = 1; year <= this.defaultProjectLength; year++) { - annualAvoidedLoss[year] = 0; - } - - for (const year in annualAvoidedLoss) { - const yearNum = Number(year); - if (yearNum <= this.projectLength) { - if (yearNum === 1) { - annualAvoidedLoss[yearNum] = - (projectedLoss[yearNum] - projectedLoss[-1]) * -1; - } else { - annualAvoidedLoss[yearNum] = - (projectedLoss[yearNum] - projectedLoss[yearNum - 1]) * -1; - } - } else { - annualAvoidedLoss[yearNum] = 0; + for (let year = -1; year <= this.defaultProjectLength; year++) { + if (year !== 0) { + netEmissionReductionsPlan[year] = 0; } } - return annualAvoidedLoss; - } + if (this.activity === ACTIVITY.CONSERVATION) { + netEmissionReductionsPlan = this._calculateConservationEmissions( + netEmissionReductionsPlan, + ); + } - public calculateCumulativeLossRate(): { [year: number]: number } { - if (this.project.activity !== ACTIVITY.CONSERVATION) { - throw new Error( - 'Cumulative loss rate can only be calculated for conservation projects.', + if (this.activity === ACTIVITY.RESTORATION) { + netEmissionReductionsPlan = this._calculateRestorationEmissions( + netEmissionReductionsPlan, ); } - const cumulativeLossRate: { [year: number]: number } = {}; - const annualAvoidedLoss = this.calculateAnnualAvoidedLoss(); + return netEmissionReductionsPlan; + } - for (let year = 1; year <= this.defaultProjectLength; year++) { - cumulativeLossRate[year] = 0; - } + private _calculateConservationEmissions( + netEmissionReductionsPlan: CostPlanMap, + ): CostPlanMap { + const baselineEmissions: CostPlanMap = this.calculateBaselineEmissions(); + + for (const yearStr in netEmissionReductionsPlan) { + const year = Number(yearStr); - for (const year in cumulativeLossRate) { - const yearNum = Number(year); - if (yearNum <= this.projectLength) { - if (yearNum === 1) { - cumulativeLossRate[yearNum] = annualAvoidedLoss[yearNum]; + if (year <= this.projectLength) { + if (year === -1) { + netEmissionReductionsPlan[year] = 0; } else { - cumulativeLossRate[yearNum] = - annualAvoidedLoss[yearNum] + cumulativeLossRate[yearNum - 1]; + netEmissionReductionsPlan[year] = baselineEmissions[year]; } } else { - cumulativeLossRate[yearNum] = 0; + netEmissionReductionsPlan[year] = 0; } } - return cumulativeLossRate; + return netEmissionReductionsPlan; } - public calculateCumulativeLossRateIncorporatingSOCReleaseTime(): { + private _calculateRestorationEmissions(netEmissionReductionsPlan: { [year: number]: number; - } { - if (this.project.activity !== ACTIVITY.CONSERVATION) { - throw new Error( - 'Cumulative loss rate can only be calculated for conservation projects.', - ); - } - - const cumulativeLossRateIncorporatingSOC: { [year: number]: number } = {}; - const cumulativeLoss = this.calculateCumulativeLossRate(); - - // Inicializamos el plan con años de 1 a defaultProjectLength - for (let year = 1; year <= this.defaultProjectLength; year++) { - cumulativeLossRateIncorporatingSOC[year] = 0; - } - - // Calculamos la tasa de pérdida acumulativa incorporando el tiempo de liberación de SOC - for (const year in cumulativeLossRateIncorporatingSOC) { - const yearNum = Number(year); - if (yearNum <= this.projectLength) { - if (yearNum > this.project.soilOrganicCarbonReleaseLength) { - const offsetValue = - cumulativeLoss[ - yearNum - this.project.soilOrganicCarbonReleaseLength - ]; - cumulativeLossRateIncorporatingSOC[yearNum] = - cumulativeLoss[yearNum] - offsetValue; + }): CostPlanMap { + const areaRestoredOrConservedPlan: { [year: number]: number } = + this.calculateAreaRestoredOrConserved(); + const sequestrationRate: number = 0; + // TODO: Sequestration rate is for Restoration projects, still need to implement + //this.projectInput.assumptions.sequestrationRate; + + for (const yearStr in netEmissionReductionsPlan) { + const year = Number(yearStr); + if (year <= this.projectLength) { + if (year === -1) { + netEmissionReductionsPlan[year] = 0; + // } else if (this.projectInput.restoration_activity === 'Planting') { + // netEmissionReductionsPlan[year] = this._calculatePlantingEmissions( + // areaRestoredOrConservedPlan, + // sequestrationRate, + // year, + // ); } else { - cumulativeLossRateIncorporatingSOC[yearNum] = cumulativeLoss[yearNum]; + if (year === 1) { + netEmissionReductionsPlan[year] = + areaRestoredOrConservedPlan[-1] * sequestrationRate; + } else { + netEmissionReductionsPlan[year] = + areaRestoredOrConservedPlan[year - 1] * sequestrationRate; + } } } else { - cumulativeLossRateIncorporatingSOC[yearNum] = 0; + netEmissionReductionsPlan[year] = 0; } } - - return cumulativeLossRateIncorporatingSOC; + return netEmissionReductionsPlan; } - public calculateBaselineEmissions(): { [year: number]: number } { - if (this.project.activity !== ACTIVITY.CONSERVATION) { - throw new Error( - 'Baseline emissions can only be calculated for conservation projects.', + private _calculatePlantingEmissions( + areaRestoredOrConservedPlan: CostPlanMap, + sequestrationRate: number, + year: number, + ): number { + const plantingSuccessRate: number = this.plantingSuccessRate; + + if (year === 1) { + return ( + areaRestoredOrConservedPlan[year - 2] * + sequestrationRate * + plantingSuccessRate + ); + } else { + return ( + areaRestoredOrConservedPlan[year - 1] * + sequestrationRate * + plantingSuccessRate ); } + } - const sequestrationRateTier1 = - this.project.costInputs.tier1SequestrationRate; - let emissionFactor: number | undefined; - let emissionFactorAGB: number | undefined; - let emissionFactorSOC: number | undefined; - - if (this.project.emissionFactorUsed === 'Tier 1 - Global emission factor') { - emissionFactor = this.project.emissionFactor; - } else if ( - this.project.emissionFactorUsed === - 'Tier 2 - Country-specific emission factor' - ) { - emissionFactorAGB = this.project.emissionFactorAGB; - emissionFactorSOC = this.project.emissionFactorSOC; - } else { - emissionFactorAGB = this.project.emissionFactorAGB; - emissionFactorSOC = this.project.emissionFactorSOC; + calculateBaselineEmissions(): CostPlanMap { + // TODO: This is validated previously, but letting it here until we understand what value should we provide for Restoration, + // as all costs are calculated for both types. Maybe this is an internal method and the value is set in another place. + if (this.activity !== ACTIVITY.CONSERVATION) { + console.error('Baseline emissions cannot be calculated for restoration.'); } + const { emissionFactorAgb, emissionFactorSoc, emissionFactor } = + this.projectInput; + const tier1SequestrationRate = this.tier1SequestrationRate; + const baselineEmissionPlan: { [year: number]: number } = {}; + for (let year = 1; year <= this.defaultProjectLength; year++) { + if (year !== 0) { + baselineEmissionPlan[year] = 0; + } + } + const cumulativeLoss = this.calculateCumulativeLossRate(); const cumulativeLossRateIncorporatingSOC = this.calculateCumulativeLossRateIncorporatingSOCReleaseTime(); const annualAvoidedLoss = this.calculateAnnualAvoidedLoss(); - for (let year = 1; year <= this.defaultProjectLength; year++) { - baselineEmissionPlan[year] = 0; - } - - for (const year in baselineEmissionPlan) { - const yearNum = Number(year); - if (yearNum <= this.projectLength) { - if ( - this.project.emissionFactorUsed !== 'Tier 1 - Global emission factor' - ) { - baselineEmissionPlan[yearNum] = - emissionFactorAGB! * annualAvoidedLoss[yearNum] + - cumulativeLossRateIncorporatingSOC[yearNum] * emissionFactorSOC! + - sequestrationRateTier1 * cumulativeLoss[yearNum]; + for (const yearStr in baselineEmissionPlan) { + const year = Number(yearStr); + let value: number = 0; + if (year <= this.projectLength) { + if (emissionFactorSoc && emissionFactorAgb) { + value = + emissionFactorAgb * annualAvoidedLoss[year] + + cumulativeLossRateIncorporatingSOC[year] * emissionFactorSoc + + tier1SequestrationRate * cumulativeLoss[year]; } else { - baselineEmissionPlan[yearNum] = - cumulativeLoss[yearNum] * emissionFactor! + - sequestrationRateTier1 * cumulativeLoss[yearNum]; + value = + cumulativeLoss[year] * emissionFactor + + tier1SequestrationRate * cumulativeLoss[year]; } + baselineEmissionPlan[year] = value; } else { - baselineEmissionPlan[yearNum] = 0; + baselineEmissionPlan[year] = 0; } } return baselineEmissionPlan; } - public calculateNetEmissionsReductions(): { [year: number]: number } { - const netEmissionReductionsPlan: { [year: number]: number } = {}; + calculateAreaRestoredOrConserved(): CostPlanMap { + const cumulativeHaRestoredInYear: CostPlanMap = {}; for (let year = -1; year <= this.defaultProjectLength; year++) { if (year !== 0) { - netEmissionReductionsPlan[year] = 0; + cumulativeHaRestoredInYear[year] = 0; } } - if (this.project.activity === ACTIVITY.CONSERVATION) { - return this.calculateConservationEmissions(netEmissionReductionsPlan); - } else if (this.project.activity === ACTIVITY.RESTORATION) { - return this.calculateRestorationEmissions(netEmissionReductionsPlan); + for (const yearStr in cumulativeHaRestoredInYear) { + const year = Number(yearStr); + + if (year > this.projectLength) { + cumulativeHaRestoredInYear[year] = 0; + } else if (this.activity === ACTIVITY.RESTORATION) { + if (this.restorationRate < this.projectInput.projectSizeHa) { + cumulativeHaRestoredInYear[year] = this.restorationRate; + } else { + cumulativeHaRestoredInYear[year] = this.projectInput.projectSizeHa; + } + } else { + cumulativeHaRestoredInYear[year] = this.projectInput.projectSizeHa; + } } - return netEmissionReductionsPlan; + return cumulativeHaRestoredInYear; } - private calculateRestorationEmissions(netEmissionReductionsPlan: { - [year: number]: number; - }): { [year: number]: number } { - const areaRestoredOrConservedPlan = this.calculateAreaRestoredOrConserved(); - const sequestrationRate = this.sequestrationRate; - - for (const year in netEmissionReductionsPlan) { - const yearNum = Number(year); - if (yearNum <= this.projectLength) { - if (yearNum === -1) { - netEmissionReductionsPlan[yearNum] = 0; - } else if (this.activitySubType === 'Planting') { - netEmissionReductionsPlan[yearNum] = this.calculatePlantingEmissions( - areaRestoredOrConservedPlan, - sequestrationRate, - yearNum, - ); + calculateCumulativeLossRate(): CostPlanMap { + if (this.activity !== ACTIVITY.CONSERVATION) { + console.error( + 'Cumulative loss rate cannot be calculated for restoration.', + ); + throw new Error( + 'Cumulative loss rate cannot be calculated for restoration.', + ); + } + + const cumulativeLossRate: CostPlanMap = {}; + + for (let year = 1; year <= this.defaultProjectLength; year++) { + cumulativeLossRate[year] = 0; + } + + const annualAvoidedLoss: { [year: number]: number } = + this.calculateAnnualAvoidedLoss(); + + for (const yearStr in cumulativeLossRate) { + const year = Number(yearStr); + + if (year <= this.projectLength) { + if (year === 1) { + cumulativeLossRate[year] = annualAvoidedLoss[year]; } else { - netEmissionReductionsPlan[yearNum] = - areaRestoredOrConservedPlan[yearNum - 1] * sequestrationRate; + cumulativeLossRate[year] = + annualAvoidedLoss[year] + cumulativeLossRate[year - 1]; } } else { - netEmissionReductionsPlan[yearNum] = 0; + cumulativeLossRate[year] = 0; } } - return netEmissionReductionsPlan; + return cumulativeLossRate; } - private calculatePlantingEmissions( - areaRestoredOrConservedPlan: { [year: number]: number }, - sequestrationRate: number, - year: number, - ): number { - const plantingSuccessRate = this.project.plantingSuccessRate; - - if (year === 1) { - return ( - areaRestoredOrConservedPlan[year - 2] * - sequestrationRate * - plantingSuccessRate + calculateCumulativeLossRateIncorporatingSOCReleaseTime(): CostPlanMap { + if (this.activity !== ACTIVITY.CONSERVATION) { + throw new Error( + 'Cumulative loss rate incorporating SOC cannot be calculated for restoration projects.', ); } - return ( - areaRestoredOrConservedPlan[year - 1] * - sequestrationRate * - plantingSuccessRate - ); - } + const cumulativeLossRateIncorporatingSOC: CostPlanMap = {}; - public calculateAreaRestoredOrConserved(): { [year: number]: number } { - const cumulativeHaRestoredInYear: { [year: number]: number } = {}; - - for (let year = -1; year <= this.defaultProjectLength; year++) { - if (year !== 0) { - cumulativeHaRestoredInYear[year] = 0; - } + for (let year = 1; year <= this.defaultProjectLength; year++) { + cumulativeLossRateIncorporatingSOC[year] = 0; } - for (const year in cumulativeHaRestoredInYear) { - const yearNum = Number(year); - if (yearNum > this.projectLength) { - cumulativeHaRestoredInYear[yearNum] = 0; - } else if (this.activity === ACTIVITY.RESTORATION) { - cumulativeHaRestoredInYear[yearNum] = Math.min( - this.project.restorationRate, - this.project.projectSizeHa, - ); + const cumulativeLoss = this.calculateCumulativeLossRate(); + + for (const yearStr in cumulativeLossRateIncorporatingSOC) { + const year = Number(yearStr); + + if (year <= this.projectLength) { + if (year > this.soilOrganicCarbonReleaseLength) { + const offsetYear = year - this.soilOrganicCarbonReleaseLength; + const offsetValue = cumulativeLoss[offsetYear]; + cumulativeLossRateIncorporatingSOC[year] = + cumulativeLoss[year] - offsetValue; + } else { + cumulativeLossRateIncorporatingSOC[year] = cumulativeLoss[year]; + } } else { - cumulativeHaRestoredInYear[yearNum] = this.project.projectSizeHa; + cumulativeLossRateIncorporatingSOC[year] = 0; } } - return cumulativeHaRestoredInYear; + return cumulativeLossRateIncorporatingSOC; } - public calculateImplementationLabor(): { [year: number]: number } { - const baseCost = - this.activity === ACTIVITY.RESTORATION - ? this.project.costInputs.implementationLabor - : 0; - const areaRestoredOrConservedPlan = this.calculateAreaRestoredOrConserved(); - const implementationLaborCostPlan: { [year: number]: number } = {}; - - for (let year = -4; year <= this.defaultProjectLength; year++) { - if (year !== 0) { - implementationLaborCostPlan[year] = 0; - } + calculateAnnualAvoidedLoss(): CostPlanMap { + if (this.activity !== ACTIVITY.CONSERVATION) { + throw new Error( + 'Annual avoided loss can only be calculated for conservation projects.', + ); } - for (let year = 1; year <= this.projectLength; year++) { - const laborCost = - baseCost * - (areaRestoredOrConservedPlan[year] - - (areaRestoredOrConservedPlan[year - 1] || 0)); - implementationLaborCostPlan[year] = laborCost; + const projectedLoss: { [year: number]: number } = + this.calculateProjectedLoss(); + + const annualAvoidedLoss: { [year: number]: number } = {}; + for (let year = 1; year <= this.defaultProjectLength; year++) { + annualAvoidedLoss[year] = 0; } - return implementationLaborCostPlan; - } - private calculateConservationEmissions(netEmissionReductionsPlan: { - [year: number]: number; - }): { [year: number]: number } { - const baselineEmissions = this.calculateBaselineEmissions(); - - for (const year in netEmissionReductionsPlan) { - const yearNum = Number(year); - if (yearNum <= this.projectLength) { - if (yearNum === -1) { - netEmissionReductionsPlan[yearNum] = 0; + for (const yearStr in annualAvoidedLoss) { + const year = Number(yearStr); + + if (year <= this.projectLength) { + if (year === 1) { + annualAvoidedLoss[year] = + (projectedLoss[year] - projectedLoss[-1]) * -1; } else { - netEmissionReductionsPlan[yearNum] = baselineEmissions[yearNum]; + annualAvoidedLoss[year] = + (projectedLoss[year] - projectedLoss[year - 1]) * -1; } } else { - netEmissionReductionsPlan[yearNum] = 0; + annualAvoidedLoss[year] = 0; } } - return netEmissionReductionsPlan; + return annualAvoidedLoss; } - public calculateEstimatedCreditsIssued(): { [year: number]: number } { - const estCreditsIssuedPlan: { [year: number]: number } = {}; + calculateProjectedLoss(): { [year: number]: number } { + if (this.activity !== ACTIVITY.CONSERVATION) { + throw new Error( + 'Projected loss can only be calculated for conservation projects.', + ); + } + + const lossRate = this.projectInput.lossRate; + const projectSizeHa = this.projectInput.projectSizeHa; + + const annualProjectedLoss: { [year: number]: number } = {}; for (let year = -1; year <= this.defaultProjectLength; year++) { if (year !== 0) { - estCreditsIssuedPlan[year] = 0; + annualProjectedLoss[year] = 0; } } - const netEmissionsReductions = this.calculateNetEmissionsReductions(); + for (const yearStr in annualProjectedLoss) { + const year = Number(yearStr); - for (const year in estCreditsIssuedPlan) { - const yearNum = Number(year); - if (yearNum <= this.projectLength) { - estCreditsIssuedPlan[yearNum] = - netEmissionsReductions[yearNum] * (1 - this.project.buffer); + if (year <= this.projectLength) { + if (year === -1) { + annualProjectedLoss[year] = projectSizeHa; + } else { + annualProjectedLoss[year] = + projectSizeHa * Math.pow(1 + lossRate, year); + } } else { - estCreditsIssuedPlan[yearNum] = 0; + annualProjectedLoss[year] = 0; } } - return estCreditsIssuedPlan; + return annualProjectedLoss; } } diff --git a/api/src/modules/config/app-config.module.ts b/api/src/modules/config/app-config.module.ts index de6c3806..41cf4166 100644 --- a/api/src/modules/config/app-config.module.ts +++ b/api/src/modules/config/app-config.module.ts @@ -19,6 +19,26 @@ import { JwtConfigHandler } from '@api/modules/config/auth-config.handler'; resolveConfigPath(`shared/config/.env.${process.env.NODE_ENV}`), resolveConfigPath(`shared/config/.env`), ], + validate(config) { + const expectedVariables = [ + 'BACKOFFICE_SESSION_COOKIE_NAME', + 'BACKOFFICE_SESSION_COOKIE_SECRET', + ]; + + const missingVariables = []; + for (const expectedVariable of expectedVariables) { + if (config[expectedVariable] === undefined) { + missingVariables.push(expectedVariable); + } + } + + if (missingVariables.length > 0) { + throw new Error( + `Missing required environment variables: ${missingVariables.join(', ')}`, + ); + } + return config; + }, }), DatabaseModule, ], diff --git a/api/src/modules/countries/countries.service.ts b/api/src/modules/countries/countries.service.ts index 547f5a59..8d2d51b6 100644 --- a/api/src/modules/countries/countries.service.ts +++ b/api/src/modules/countries/countries.service.ts @@ -16,7 +16,7 @@ export class CountriesService extends AppBaseService< @InjectRepository(Country) private readonly countryRepository: Repository, ) { - super(countryRepository, 'country', 'countries'); + super(countryRepository, 'country', 'countries', 'code'); } async getAvailableCountriesToCreateACustomProject(): Promise { diff --git a/api/src/modules/custom-projects/custom-projects.controller.ts b/api/src/modules/custom-projects/custom-projects.controller.ts index 057348f9..dd3e119f 100644 --- a/api/src/modules/custom-projects/custom-projects.controller.ts +++ b/api/src/modules/custom-projects/custom-projects.controller.ts @@ -1,11 +1,22 @@ -import { Body, Controller, HttpStatus, ValidationPipe } from '@nestjs/common'; +import { + Body, + Controller, + HttpStatus, + UseGuards, + ValidationPipe, +} from '@nestjs/common'; import { CountriesService } from '@api/modules/countries/countries.service'; import { tsRestHandler, TsRestHandler } from '@ts-rest/nest'; 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'; +import { GetUser } from '@api/decorators/get-user.decorator'; +import { User } from '@shared/entities/users/user.entity'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from '@api/modules/auth/guards/roles.guard'; +import { RequiredRoles } from '@api/modules/auth/decorators/roles.decorator'; +import { ROLES } from '@shared/entities/users/roles.enum'; @Controller() export class CustomProjectsController { @@ -67,15 +78,14 @@ export class CustomProjectsController { ); } - @TsRestHandler(customProjectContract.snapshotCustomProject) - async snapshot( - @Body(new ValidationPipe({ enableDebugMessages: true, transform: true })) - dto: CustomProjectSnapshotDto, - ): Promise { + @UseGuards(AuthGuard('jwt'), RolesGuard) + @RequiredRoles(ROLES.PARTNER, ROLES.ADMIN) + @TsRestHandler(customProjectContract.saveCustomProject) + async snapshot(@GetUser() user: User): Promise { return tsRestHandler( - customProjectContract.snapshotCustomProject, + customProjectContract.saveCustomProject, async ({ body }) => { - await this.customProjects.saveCustomProject(dto); + await this.customProjects.saveCustomProject(body, user); return { status: 201, body: null, diff --git a/api/src/modules/custom-projects/custom-projects.module.ts b/api/src/modules/custom-projects/custom-projects.module.ts index 1db27538..7f7a4405 100644 --- a/api/src/modules/custom-projects/custom-projects.module.ts +++ b/api/src/modules/custom-projects/custom-projects.module.ts @@ -5,7 +5,8 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { CustomProject } from '@shared/entities/custom-project.entity'; import { CustomProjectsController } from './custom-projects.controller'; import { CalculationsModule } from '@api/modules/calculations/calculations.module'; -import { CustomProjectInputFactory } from '@api/modules/custom-projects/input-factory/custom-project-input.factory'; +import { CustomProjectFactory } from '@api/modules/custom-projects/input-factory/custom-project.factory'; +import { SaveCustomProjectEventHandler } from '@api/modules/custom-projects/events/handlers/save-custom-project.handler'; @Module({ imports: [ @@ -13,7 +14,11 @@ import { CustomProjectInputFactory } from '@api/modules/custom-projects/input-fa CountriesModule, CalculationsModule, ], - providers: [CustomProjectsService, CustomProjectInputFactory], + providers: [ + CustomProjectsService, + CustomProjectFactory, + SaveCustomProjectEventHandler, + ], controllers: [CustomProjectsController], }) export class CustomProjectsModule {} diff --git a/api/src/modules/custom-projects/custom-projects.service.ts b/api/src/modules/custom-projects/custom-projects.service.ts index c441b319..f1686288 100644 --- a/api/src/modules/custom-projects/custom-projects.service.ts +++ b/api/src/modules/custom-projects/custom-projects.service.ts @@ -1,18 +1,23 @@ -import { Injectable } from '@nestjs/common'; +import { + Injectable, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; import { AppBaseService } from '@api/utils/app-base.service'; import { CreateCustomProjectDto } from '@api/modules/custom-projects/dto/create-custom-project-dto'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { CustomProject } from '@shared/entities/custom-project.entity'; import { CalculationEngine } from '@api/modules/calculations/calculation.engine'; -import { CustomProjectInputFactory } from '@api/modules/custom-projects/input-factory/custom-project-input.factory'; +import { CustomProjectFactory } from '@api/modules/custom-projects/input-factory/custom-project.factory'; import { GetOverridableCostInputs } from '@shared/dtos/custom-projects/get-overridable-cost-inputs.dto'; 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'; +import { User } from '@shared/entities/users/user.entity'; +import { EventBus } from '@nestjs/cqrs'; +import { SaveCustomProjectEvent } from '@api/modules/custom-projects/events/save-custom-project.event'; @Injectable() export class CustomProjectsService extends AppBaseService< @@ -21,50 +26,64 @@ export class CustomProjectsService extends AppBaseService< unknown, unknown > { + logger = new Logger(CustomProjectsService.name); constructor( @InjectRepository(CustomProject) public readonly repo: Repository, public readonly calculationEngine: CalculationEngine, public readonly dataRepository: DataRepository, public readonly assumptionsRepository: AssumptionsRepository, - public readonly customProjectFactory: CustomProjectInputFactory, + public readonly customProjectFactory: CustomProjectFactory, + private readonly eventBus: EventBus, ) { super(repo, 'customProject', 'customProjects'); } async create(dto: CreateCustomProjectDto): Promise { const { countryCode, ecosystem, activity } = dto; - const { defaultCarbonInputs, baseIncrease, baseSize } = - await this.dataRepository.getDataForCalculation({ - countryCode, - ecosystem, - activity, - }); + const { + additionalBaseData, + baseIncrease, + baseSize, + additionalAssumptions, + } = await this.dataRepository.getDataForCalculation({ + countryCode, + ecosystem, + activity, + }); const projectInput = this.customProjectFactory.createProjectInput( dto, - defaultCarbonInputs, + additionalBaseData, + additionalAssumptions, ); - // TODO: Don't know where this values should come from. i.e default project length comes from the assumptions based on activity? In the python calcs, the same - // value is used regardless of the activity. - const DEFAULT_PROJECT_LENGTH = 40; - const CONSERVATION_STARTING_POINT_SCALING = 500; - const RESTORATION_STARTING_POINT_SCALING = 20000; - const calculator = new CostCalculator( + const costOutput = this.calculationEngine.calculateCostOutput({ projectInput, - DEFAULT_PROJECT_LENGTH, - CONSERVATION_STARTING_POINT_SCALING, - baseSize, baseIncrease, + baseSize, + }); + + const customProject = this.customProjectFactory.createProject( + dto, + projectInput, + costOutput, ); - calculator.initializeCostPlans().calculateCosts(); - return calculator.costPlans; + return customProject; } - async saveCustomProject(dto: CustomProjectSnapshotDto): Promise { - await this.repo.save(CustomProject.fromCustomProjectSnapshotDTO(dto)); + async saveCustomProject(dto: CustomProject, user: User): Promise { + try { + await this.repo.save({ ...dto, user }); + this.eventBus.publish(new SaveCustomProjectEvent(user.id, true)); + } catch (error) { + this.logger.error(`Error saving custom project: ${error}`); + this.eventBus.publish(new SaveCustomProjectEvent(user.id, false, error)); + throw new ServiceUnavailableException( + `Custom project could not be saved, please try again later`, + ); + } } async getDefaultCostInputs( 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 index 94fd00ea..efb468dc 100644 --- a/api/src/modules/custom-projects/dto/custom-project-snapshot.dto.ts +++ b/api/src/modules/custom-projects/dto/custom-project-snapshot.dto.ts @@ -6,6 +6,7 @@ import { IsOptional, } from 'class-validator'; import { CreateCustomProjectDto } from './create-custom-project-dto'; +import { CustomProjectOutput } from '@shared/dtos/custom-projects/custom-project-output.dto'; export class CustomPrpjectAnnualProjectCashFlowDto { @IsArray() @@ -170,5 +171,5 @@ export class CustomProjectSnapshotDto { inputSnapshot: CreateCustomProjectDto; @IsNotEmpty() - outputSnapshot: CustomProjectOutputSnapshot; + outputSnapshot: CustomProjectOutput; } diff --git a/api/src/modules/custom-projects/events/handlers/save-custom-project.handler.ts b/api/src/modules/custom-projects/events/handlers/save-custom-project.handler.ts new file mode 100644 index 00000000..a8539f26 --- /dev/null +++ b/api/src/modules/custom-projects/events/handlers/save-custom-project.handler.ts @@ -0,0 +1,22 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { ApiEventsService } from '@api/modules/api-events/api-events.service'; +import { API_EVENT_TYPES } from '@api/modules/api-events/events.enum'; +import { SaveCustomProjectEvent } from '@api/modules/custom-projects/events/save-custom-project.event'; + +@EventsHandler(SaveCustomProjectEvent) +export class SaveCustomProjectEventHandler + implements IEventHandler +{ + constructor(private readonly apiEventsService: ApiEventsService) {} + + async handle(event: SaveCustomProjectEvent): Promise { + const eventType = event.success + ? API_EVENT_TYPES.CUSTOM_PROJECT_SAVED + : API_EVENT_TYPES.ERROR_SAVING_CUSTOM_PROJECT; + await this.apiEventsService.create({ + eventType, + resourceId: event.userId, + payload: event.payload, + }); + } +} diff --git a/api/src/modules/custom-projects/events/save-custom-project.event.ts b/api/src/modules/custom-projects/events/save-custom-project.event.ts new file mode 100644 index 00000000..05a1b4da --- /dev/null +++ b/api/src/modules/custom-projects/events/save-custom-project.event.ts @@ -0,0 +1,7 @@ +export class SaveCustomProjectEvent { + constructor( + public readonly userId: string, + public readonly success: boolean, + public readonly payload: any = {}, + ) {} +} diff --git a/api/src/modules/custom-projects/input-factory/conservation-project.input.ts b/api/src/modules/custom-projects/input-factory/conservation-project.input.ts index 68f64ebc..0433d3af 100644 --- a/api/src/modules/custom-projects/input-factory/conservation-project.input.ts +++ b/api/src/modules/custom-projects/input-factory/conservation-project.input.ts @@ -7,12 +7,13 @@ import { ConservationProjectParamDto, PROJECT_EMISSION_FACTORS, } from '@api/modules/custom-projects/dto/conservation-project-params.dto'; -import { CarbonInputs } from '@api/modules/calculations/data.repository'; +import { AdditionalBaseData } from '@api/modules/calculations/data.repository'; import { LOSS_RATE_USED } from '@shared/schemas/custom-projects/create-custom-project.schema'; +import { GeneralProjectInputs } from '@api/modules/custom-projects/input-factory/custom-project.factory'; import { - ConservationProjectCarbonInputs, - GeneralProjectInputs, -} from '@api/modules/custom-projects/input-factory/custom-project-input.factory'; + ModelAssumptionsForCalculations, + NonOverridableModelAssumptions, +} from '@api/modules/calculations/assumptions.repository'; export class ConservationProjectInput { countryCode: string; @@ -29,54 +30,69 @@ export class ConservationProjectInput { carbonRevenuesToCover: CARBON_REVENUES_TO_COVER; - carbonInputs: ConservationProjectCarbonInputs = { - lossRate: 0, - emissionFactor: 0, - emissionFactorAgb: 0, - emissionFactorSoc: 0, - }; + // TODO: Below are not ALL properties of BaseDataView, type properly once the whole flow is clear + // costAndCarbonInputs: + // | Partial + // | (OverridableCostInputs & AdditionalBaseData); - costInputs: OverridableCostInputs = new OverridableCostInputs(); + costAndCarbonInputs: OverridableCostInputs & AdditionalBaseData; - modelAssumptions: OverridableAssumptions = new OverridableAssumptions(); + lossRate: number; + + emissionFactor: number; + + emissionFactorAgb: number; + + emissionFactorSoc: number; + + assumptions: ModelAssumptionsForCalculations; setLossRate( parameters: ConservationProjectParamDto, - carbonInputs: CarbonInputs, + carbonInputs: AdditionalBaseData, ): this { if (parameters.lossRateUsed === LOSS_RATE_USED.NATIONAL_AVERAGE) { - this.carbonInputs.lossRate = carbonInputs.ecosystemLossRate; + this.lossRate = carbonInputs.ecosystemLossRate; } if (parameters.lossRateUsed === LOSS_RATE_USED.PROJECT_SPECIFIC) { - this.carbonInputs.lossRate = parameters.projectSpecificLossRate; + this.lossRate = parameters.projectSpecificLossRate; } return this; } setEmissionFactor( parameters: ConservationProjectParamDto, - carbonInputs: CarbonInputs, + additionalBaseData: AdditionalBaseData, ): this { if (parameters.emissionFactorUsed === PROJECT_EMISSION_FACTORS.TIER_1) { - this.carbonInputs.emissionFactor = carbonInputs.tier1EmissionFactor; - this.carbonInputs.emissionFactorAgb = null; - this.carbonInputs.emissionFactorSoc = null; + this.emissionFactor = additionalBaseData.tier1EmissionFactor; + this.emissionFactorAgb = null; + this.emissionFactorSoc = null; } if (parameters.emissionFactorUsed === PROJECT_EMISSION_FACTORS.TIER_2) { - this.carbonInputs.emissionFactorAgb = carbonInputs.emissionFactorAgb; - this.carbonInputs.emissionFactorSoc = carbonInputs.emissionFactorSoc; - this.carbonInputs.emissionFactor = null; + this.emissionFactorAgb = additionalBaseData.emissionFactorAgb; + this.emissionFactorSoc = additionalBaseData.emissionFactorSoc; + this.emissionFactor = null; } return this; } - setModelAssumptions(modelAssumptions: OverridableAssumptions): this { - this.modelAssumptions = modelAssumptions; + setModelAssumptions( + overridableAssumptions: OverridableAssumptions, + rest: NonOverridableModelAssumptions, + ): this { + this.assumptions = { ...overridableAssumptions, ...rest }; return this; } - setCostInputs(costInputs: OverridableCostInputs): this { - this.costInputs = costInputs; + setCostAndCarbonInputs( + overridableCostInputs: OverridableCostInputs, + additionalBaseData: AdditionalBaseData, + ): this { + this.costAndCarbonInputs = { + ...overridableCostInputs, + ...additionalBaseData, + }; return this; } diff --git a/api/src/modules/custom-projects/input-factory/custom-project-input.factory.ts b/api/src/modules/custom-projects/input-factory/custom-project-input.factory.ts deleted file mode 100644 index ebe9e9c2..00000000 --- a/api/src/modules/custom-projects/input-factory/custom-project-input.factory.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Injectable, NotImplementedException } from '@nestjs/common'; -import { ACTIVITY } from '@shared/entities/activity.enum'; -import { ConservationProjectParamDto } from '@api/modules/custom-projects/dto/conservation-project-params.dto'; -import { CarbonInputs } from '@api/modules/calculations/data.repository'; - -import { CreateCustomProjectDto } from '@api/modules/custom-projects/dto/create-custom-project-dto'; -import { ConservationProjectInput } from '@api/modules/custom-projects/input-factory/conservation-project.input'; - -export type ConservationProjectCarbonInputs = { - lossRate: number; - emissionFactor: number | null; - emissionFactorAgb: number | null; - emissionFactorSoc: number | null; -}; - -export type GeneralProjectInputs = { - projectName: CreateCustomProjectDto['projectName']; - countryCode: CreateCustomProjectDto['countryCode']; - activity: CreateCustomProjectDto['activity']; - ecosystem: CreateCustomProjectDto['ecosystem']; - projectSizeHa: CreateCustomProjectDto['projectSizeHa']; - initialCarbonPriceAssumption: CreateCustomProjectDto['initialCarbonPriceAssumption']; - carbonRevenuesToCover: CreateCustomProjectDto['carbonRevenuesToCover']; -}; - -@Injectable() -export class CustomProjectInputFactory { - createProjectInput(dto: CreateCustomProjectDto, carbonInputs: CarbonInputs) { - if (dto.activity === ACTIVITY.CONSERVATION) { - return this.createConservationProjectInput(dto, carbonInputs); - } else if (dto.activity === ACTIVITY.RESTORATION) { - throw new NotImplementedException('Restoration not implemented'); - } else { - throw new Error('Invalid activity type'); - } - } - - private createConservationProjectInput( - dto: CreateCustomProjectDto, - carbonInputs: CarbonInputs, - ): ConservationProjectInput { - const { - parameters, - assumptions, - costInputs, - projectName, - projectSizeHa, - initialCarbonPriceAssumption, - activity, - carbonRevenuesToCover, - ecosystem, - countryCode, - } = dto; - - const projectParams = parameters as ConservationProjectParamDto; - - const conservationProjectInput: ConservationProjectInput = - new ConservationProjectInput(); - conservationProjectInput.setGeneralInputs({ - projectName, - projectSizeHa, - initialCarbonPriceAssumption, - activity, - carbonRevenuesToCover, - ecosystem, - countryCode, - }); - conservationProjectInput.setLossRate(projectParams, carbonInputs); - conservationProjectInput.setEmissionFactor(projectParams, carbonInputs); - conservationProjectInput.setCostInputs(costInputs); - conservationProjectInput.setModelAssumptions(assumptions); - - return conservationProjectInput; - } -} diff --git a/api/src/modules/custom-projects/input-factory/custom-project.factory.ts b/api/src/modules/custom-projects/input-factory/custom-project.factory.ts new file mode 100644 index 00000000..b4941594 --- /dev/null +++ b/api/src/modules/custom-projects/input-factory/custom-project.factory.ts @@ -0,0 +1,148 @@ +import { Injectable, NotImplementedException } from '@nestjs/common'; +import { ACTIVITY } from '@shared/entities/activity.enum'; +import { ConservationProjectParamDto } from '@api/modules/custom-projects/dto/conservation-project-params.dto'; +import { AdditionalBaseData } from '@api/modules/calculations/data.repository'; + +import { CreateCustomProjectDto } from '@api/modules/custom-projects/dto/create-custom-project-dto'; +import { ConservationProjectInput } from '@api/modules/custom-projects/input-factory/conservation-project.input'; +import { + ModelAssumptionsForCalculations, + NonOverridableModelAssumptions, +} from '@api/modules/calculations/assumptions.repository'; +import { BaseDataView } from '@shared/entities/base-data.view'; +import { CostOutput } from '@api/modules/calculations/calculation.engine'; +import { ProjectInput } from '@api/modules/calculations/cost.calculator'; +import { CustomProject } from '@shared/entities/custom-project.entity'; +import { Country } from '@shared/entities/country.entity'; + +export type ConservationProjectCarbonInputs = { + lossRate: number; + emissionFactor: number | null; + emissionFactorAgb: number | null; + emissionFactorSoc: number | null; +}; + +export type GeneralProjectInputs = { + projectName: CreateCustomProjectDto['projectName']; + countryCode: CreateCustomProjectDto['countryCode']; + activity: CreateCustomProjectDto['activity']; + ecosystem: CreateCustomProjectDto['ecosystem']; + projectSizeHa: CreateCustomProjectDto['projectSizeHa']; + initialCarbonPriceAssumption: CreateCustomProjectDto['initialCarbonPriceAssumption']; + carbonRevenuesToCover: CreateCustomProjectDto['carbonRevenuesToCover']; +}; + +@Injectable() +export class CustomProjectFactory { + createProjectInput( + dto: CreateCustomProjectDto, + additionalBaseData: AdditionalBaseData, + additionalAssumptions: NonOverridableModelAssumptions, + ) { + if (dto.activity === ACTIVITY.CONSERVATION) { + return this.createConservationProjectInput( + dto, + additionalBaseData, + additionalAssumptions, + ); + } else if (dto.activity === ACTIVITY.RESTORATION) { + throw new NotImplementedException('Restoration not implemented'); + } else { + throw new Error('Invalid activity type'); + } + } + + private createConservationProjectInput( + dto: CreateCustomProjectDto, + additionalBaseData: AdditionalBaseData, + additionalAssumptions: NonOverridableModelAssumptions, + ): ConservationProjectInput { + const { + parameters, + assumptions, + costInputs, + projectName, + projectSizeHa, + initialCarbonPriceAssumption, + activity, + carbonRevenuesToCover, + ecosystem, + countryCode, + } = dto; + + const projectParams = parameters as ConservationProjectParamDto; + + const conservationProjectInput: ConservationProjectInput = + new ConservationProjectInput(); + conservationProjectInput.setGeneralInputs({ + projectName, + projectSizeHa, + initialCarbonPriceAssumption, + activity, + carbonRevenuesToCover, + ecosystem, + countryCode, + }); + conservationProjectInput.setLossRate(projectParams, additionalBaseData); + conservationProjectInput.setEmissionFactor( + projectParams, + additionalBaseData, + ); + conservationProjectInput.setCostAndCarbonInputs( + costInputs, + additionalBaseData, + ); + conservationProjectInput.setModelAssumptions( + assumptions, + additionalAssumptions, + ); + + return conservationProjectInput; + } + + createProject( + dto: CreateCustomProjectDto, + input: ProjectInput, + output: CostOutput, + ): CustomProject { + const { costPlans, summary, costDetails, yearlyBreakdown } = output; + const customProject = new CustomProject(); + customProject.projectName = dto.projectName; + customProject.country = { code: dto.countryCode } as Country; + customProject.totalCostNPV = + costPlans.totalCapexNPV + costPlans.totalOpexNPV; + customProject.totalCost = costPlans.totalCapex + costPlans.totalOpex; + customProject.projectSize = dto.projectSizeHa; + customProject.projectLength = dto.assumptions.projectLength; + customProject.ecosystem = dto.ecosystem; + customProject.activity = dto.activity; + customProject.output = { + lossRate: input.lossRate, + carbonRevenuesToCover: input.carbonRevenuesToCover, + initialCarbonPrice: input.initialCarbonPriceAssumption, + emissionFactors: { + emissionFactor: input.emissionFactor, + emissionFactorAgb: input.emissionFactorAgb, + emissionFactorSoc: input.emissionFactorSoc, + }, + totalProjectCost: { + total: { + total: costPlans.totalCapex + costPlans.totalOpex, + capex: costPlans.totalCapex, + opex: costPlans.totalOpex, + }, + npv: { + total: costPlans.totalCapexNPV + costPlans.totalOpexNPV, + capex: costPlans.totalCapexNPV, + opex: costPlans.totalOpexNPV, + }, + }, + summary, + costDetails, + yearlyBreakdown, + }; + customProject.input = dto; + + return customProject; + } +} diff --git a/api/src/modules/import/dtos/excel-projects-scorecard.dto .ts b/api/src/modules/import/dtos/excel-projects-scorecard.dto .ts new file mode 100644 index 00000000..396feeb7 --- /dev/null +++ b/api/src/modules/import/dtos/excel-projects-scorecard.dto .ts @@ -0,0 +1,15 @@ +import { ECOSYSTEM } from '@shared/entities/ecosystem.enum'; + +export type ExcelProjectScorecard = { + country: string; + country_code: string; + ecosystem: ECOSYSTEM; + legal_feasibility: number; + implementation_risk_score: number; + availability_of_experienced_labor: number; + security_rating: number; + availability_of_alternative_funding: number; + biodiversity_benefit: number; + social_feasibility: number; + coastal_protection_benefit: number; +}; diff --git a/api/src/modules/import/import.controller.ts b/api/src/modules/import/import.controller.ts index 01f4bdc2..495d15ca 100644 --- a/api/src/modules/import/import.controller.ts +++ b/api/src/modules/import/import.controller.ts @@ -44,6 +44,25 @@ export class ImportController { }); } + @TsRestHandler(adminContract.uploadProjectScorecard) + @UseInterceptors(FileInterceptor('file')) + @RequiredRoles(ROLES.ADMIN) + async uploadProjectScorecard( + @UploadXlsm() file: Express.Multer.File, + @GetUser() user: User, + ): Promise { + return tsRestHandler(adminContract.uploadProjectScorecard, async () => { + const importedData = await this.service.importProjectScorecard( + file.buffer, + user.id, + ); + return { + status: 201, + body: importedData, + }; + }); + } + @UseInterceptors(FilesInterceptor('files', 2)) @RequiredRoles(ROLES.PARTNER, ROLES.ADMIN) @TsRestHandler(usersContract.uploadData) diff --git a/api/src/modules/import/import.repostiory.ts b/api/src/modules/import/import.repostiory.ts index 5376203f..7ca81ed2 100644 --- a/api/src/modules/import/import.repostiory.ts +++ b/api/src/modules/import/import.repostiory.ts @@ -27,11 +27,18 @@ import { ImplementationLaborCost } from '@shared/entities/cost-inputs/implementa import { BaseIncrease } from '@shared/entities/base-increase.entity'; import { BaseSize } from '@shared/entities/base-size.entity'; import { ModelAssumptions } from '@shared/entities/model-assumptions.entity'; +import { ProjectScorecard } from '@shared/entities/project-scorecard.entity'; @Injectable() export class ImportRepository { constructor(private readonly dataSource: DataSource) {} + async importProjectScorecard(projectScorecards: ProjectScorecard[]) { + return this.dataSource.transaction(async (manager) => { + await manager.save(projectScorecards); + }); + } + async ingest(importData: { projects: Project[]; projectSize: ProjectSize[]; diff --git a/api/src/modules/import/import.service.ts b/api/src/modules/import/import.service.ts index b4f5c57f..5fe1cdf2 100644 --- a/api/src/modules/import/import.service.ts +++ b/api/src/modules/import/import.service.ts @@ -36,6 +36,24 @@ export class ImportService { private readonly dataSource: DataSource, ) {} + async importProjectScorecard(fileBuffer: Buffer, userId: string) { + this.logger.warn('Project scorecard file import started...'); + this.registerImportEvent(userId, this.eventMap.STARTED); + try { + const parsedSheets = await this.excelParser.parseExcel(fileBuffer); + const parsedDBEntities = + this.preprocessor.toProjectScorecardDbEntries(parsedSheets); + + await this.importRepo.importProjectScorecard(parsedDBEntities); + + this.logger.warn('Excel file import completed successfully'); + this.registerImportEvent(userId, this.eventMap.SUCCESS); + } catch (e) { + this.logger.error('Excel file import failed', e); + this.registerImportEvent(userId, this.eventMap.FAILED); + } + } + async import(fileBuffer: Buffer, userId: string) { this.logger.warn('Excel file import started...'); this.registerImportEvent(userId, this.eventMap.STARTED); @@ -83,7 +101,7 @@ export class ImportService { await userRestorationInputsRepo.save(mappedRestorationInputs); await userConservationInputsRepo.save(mappedConservationInputs); }); - // + return carbonInputs; } } diff --git a/api/src/modules/import/services/entity.preprocessor.ts b/api/src/modules/import/services/entity.preprocessor.ts index 1f33521d..f654fa02 100644 --- a/api/src/modules/import/services/entity.preprocessor.ts +++ b/api/src/modules/import/services/entity.preprocessor.ts @@ -69,6 +69,9 @@ import { ExcelModelAssumptions } from '../dtos/excel-model-assumptions.dto'; import { BaseSize } from '@shared/entities/base-size.entity'; import { BaseIncrease } from '@shared/entities/base-increase.entity'; import { ModelAssumptions } from '@shared/entities/model-assumptions.entity'; +import { ProjectScorecard } from '@shared/entities/project-scorecard.entity'; +import { ExcelProjectScorecard } from '../dtos/excel-projects-scorecard.dto '; +import { PROJECT_SCORE } from '@shared/entities/project-score.enum'; export type ParsedDBEntities = { projects: Project[]; @@ -102,6 +105,10 @@ export type ParsedDBEntities = { @Injectable() export class EntityPreprocessor { + toProjectScorecardDbEntries(raw: {}): ProjectScorecard[] { + return this.processProjectScorecard(raw['Data_ingestion']); + } + toDbEntities(raw: { Projects: ExcelProjects[]; 'Project size': ExcelProjectSize[]; @@ -1125,6 +1132,60 @@ export class EntityPreprocessor { return parsedArray; } + private processProjectScorecard(raw: ExcelProjectScorecard[]) { + const parsedArray: ProjectScorecard[] = []; + raw.forEach((row: ExcelProjectScorecard) => { + const projectScorecard = new ProjectScorecard(); + projectScorecard.countryCode = row.country_code; + projectScorecard.ecosystem = row.ecosystem; + projectScorecard.financialFeasibility = PROJECT_SCORE.LOW; + projectScorecard.legalFeasibility = this.convertNumberToProjectScore( + row.legal_feasibility, + ); + + projectScorecard.implementationFeasibility = + this.convertNumberToProjectScore(row.implementation_risk_score); + + projectScorecard.socialFeasibility = this.convertNumberToProjectScore( + row.social_feasibility, + ); + + projectScorecard.securityRating = this.convertNumberToProjectScore( + row.security_rating, + ); + + projectScorecard.availabilityOfExperiencedLabor = + this.convertNumberToProjectScore(row.availability_of_experienced_labor); + + projectScorecard.availabilityOfAlternatingFunding = + this.convertNumberToProjectScore( + row.availability_of_alternative_funding, + ); + + projectScorecard.coastalProtectionBenefits = + this.convertNumberToProjectScore(row.coastal_protection_benefit); + projectScorecard.biodiversityBenefit = this.convertNumberToProjectScore( + row.biodiversity_benefit, + ); + + parsedArray.push(projectScorecard); + }); + + return parsedArray; + } + + private convertNumberToProjectScore(value: number): PROJECT_SCORE { + if (value === 1) { + return PROJECT_SCORE.LOW; + } + if (value === 2) { + return PROJECT_SCORE.MEDIUM; + } + if (value === 3) { + return PROJECT_SCORE.HIGH; + } + } + private emptyStringToNull(value: any): any | null { return value || null; } diff --git a/api/src/modules/import/services/excel-parser.interface.ts b/api/src/modules/import/services/excel-parser.interface.ts index 315b8e94..90e1a7f3 100644 --- a/api/src/modules/import/services/excel-parser.interface.ts +++ b/api/src/modules/import/services/excel-parser.interface.ts @@ -28,6 +28,7 @@ export const SHEETS_TO_PARSE = [ 'base_size_table', 'base_increase', 'Model assumptions', + 'Data_ingestion', ] as const; export interface ExcelParserInterface { diff --git a/api/src/modules/notifications/email/templates/welcome-email.template.ts b/api/src/modules/notifications/email/templates/welcome-email.template.ts index b63d052b..ecfe0321 100644 --- a/api/src/modules/notifications/email/templates/welcome-email.template.ts +++ b/api/src/modules/notifications/email/templates/welcome-email.template.ts @@ -7,7 +7,7 @@ export const WELCOME_EMAIL_HTML_CONTENT = (

Welcome to the TNC Blue Carbon Cost Tool Platform


-

Thank you for signing up. We're excited to have you on board. Please active you account by signing up adding a password of your choice

+

Thank you for signing up. We're excited to have you on board. Please activate your account by signing up and adding a password of your choice.

Sign Up Link


Your one-time password is ${oneTimePassword}

diff --git a/api/test/integration/auth/delete-account.spec.ts b/api/test/integration/auth/delete-account.spec.ts new file mode 100644 index 00000000..4e9235b8 --- /dev/null +++ b/api/test/integration/auth/delete-account.spec.ts @@ -0,0 +1,42 @@ +import { TestManager } from '../../utils/test-manager'; +import { HttpStatus } from '@nestjs/common'; +import { usersContract } from '@shared/contracts/users.contract'; +import { ROLES } from '@shared/entities/users/roles.enum'; + +describe('Delete Account', () => { + let testManager: TestManager; + + beforeAll(async () => { + testManager = await TestManager.createTestManager(); + }); + + afterEach(async () => { + await testManager.clearDatabase(); + }); + + afterAll(async () => { + await testManager.close(); + }); + + test("An existing user should be able to delete it's account", async () => { + // Given a user exists with valid credentials + const user = await testManager.mocks().createUser({ + role: ROLES.PARTNER, + email: 'test@test.com', + isActive: true, + password: '12345678', + }); + + const { jwtToken } = await testManager.logUserIn(user); + + // And the user tries to sign in with valid credentials + const response = await testManager + .request() + .delete(usersContract.deleteMe.path) + .set('Authorization', `Bearer ${jwtToken}`) + .send(); + + // We should get back OK response and an access token + expect(response.status).toBe(HttpStatus.OK); + }); +}); diff --git a/api/test/integration/auth/sign-in.spec.ts b/api/test/integration/auth/sign-in.spec.ts index c94cf510..c98b83ee 100644 --- a/api/test/integration/auth/sign-in.spec.ts +++ b/api/test/integration/auth/sign-in.spec.ts @@ -77,6 +77,34 @@ describe('Sign In', () => { expect(response.body.accessToken).toBeDefined(); }); + test('Should return 201 an access token and set a backoffice cookie when an admin user successfully signs in', async () => { + // Given a user exists with valid credentials + const user = await testManager.mocks().createUser({ + role: ROLES.ADMIN, + email: 'test@test.com', + isActive: true, + password: '12345678', + }); + + // And the user tries to sign in with valid credentials + const response = await testManager + .request() + .post(authContract.login.path) + .send({ + email: 'test@test.com', + password: '12345678', + }); + + // We should get back OK response and an access token + expect(response.status).toBe(HttpStatus.CREATED); + expect(response.body.accessToken).toBeDefined(); + const setCookieHeader = response.headers['set-cookie']; + expect(setCookieHeader).toHaveLength(1); + expect(decodeURIComponent(setCookieHeader[0])).toMatch( + /^backoffice=s:[^\s]+\.[^\s]+;/, + ); + }); + test('Should return UNAUTHORIZED when trying to sign in with an inactive account', async () => { // Given a user exists with valid credentials const user = await testManager.mocks().createUser({ diff --git a/api/test/integration/custom-projects/custom-projects-save.spec.ts b/api/test/integration/custom-projects/custom-projects-save.spec.ts new file mode 100644 index 00000000..2379e672 --- /dev/null +++ b/api/test/integration/custom-projects/custom-projects-save.spec.ts @@ -0,0 +1,728 @@ +import { TestManager } from '../../utils/test-manager'; +import { customProjectContract } from '@shared/contracts/custom-projects.contract'; +import { HttpStatus } from '@nestjs/common'; +import { CustomProject } from '@shared/entities/custom-project.entity'; + +describe('Snapshot Custom Projects', () => { + let testManager: TestManager; + let token: string; + + beforeAll(async () => { + testManager = await TestManager.createTestManager(); + const { jwtToken } = await testManager.setUpTestUser(); + token = jwtToken; + await testManager.ingestCountries(); + await testManager.ingestExcel(jwtToken); + }); + + afterAll(async () => { + await testManager.clearDatabase(); + await testManager.close(); + }); + + describe('Save Custom Projects', () => { + // TODO: We need to add a createCustomProject mock function for tests that can be used across apps + test('Should save a custom project', async () => { + const response = await testManager + .request() + .post(customProjectContract.saveCustomProject.path) + .set('Authorization', `Bearer ${token}`) + .send({ + projectName: 'My custom project', + abatementPotential: null, + country: { + code: 'IND', + name: 'India', + }, + totalCostNPV: 2503854.27918858, + totalCost: 3332201.598883546, + projectSize: 1000, + projectLength: 20, + ecosystem: 'Mangrove', + activity: 'Conservation', + output: { + lossRate: -0.0016, + carbonRevenuesToCover: 'Opex', + initialCarbonPrice: 1000, + emissionFactors: { + emissionFactor: null, + emissionFactorAgb: 67.7, + emissionFactorSoc: 85.5, + }, + totalProjectCost: { + total: { + total: 3332201.598883546, + capex: 1600616.6666666667, + opex: 1731584.9322168794, + }, + npv: { + total: 2503854.27918858, + capex: 1505525.2721514185, + opex: 998329.0070371614, + }, + }, + summary: { + '$/tCO2e (total cost, NPV)': 61.470410294099835, + '$/ha': 2503.8542791885798, + 'NPV covering cost': -493830.3621939037, + 'Leftover after OpEx / total cost': null, + 'IRR when priced to cover OpEx': 0.0560813585617166, + 'IRR when priced to cover total cost': 85683156958.8259, + 'Total cost (NPV)': 2503854.27918858, + 'Capital expenditure (NPV)': 1505525.2721514185, + 'Operating expenditure (NPV)': 998329.0070371614, + 'Credits issued': 26038.815407423757, + 'Total revenue (NPV)': 504498.6448432577, + 'Total revenue (non-discounted)': 956754.3382707891, + 'Financing cost': 80030.83333333334, + 'Funding gap': 493830.3621939037, + 'Funding gap (NPV)': 493830.3621939037, + 'Funding gap per tCO2e (NPV)': 18.965162372675024, + 'Community benefit sharing fund': 0.5, + }, + costDetails: { + total: { + capitalExpenditure: 1600616.6666666667, + operationalExpenditure: 1731584.9322168794, + totalCost: 3201233.3333333335, + operationExpenditure: 1731584.9322168794, + feasibilityAnalysis: 50000, + conservationPlanningAndAdmin: 667066.6666666666, + dataCollectionAndFieldCost: 80000, + communityRepresentation: 213550, + blueCarbonProjectPlanning: 300000, + establishingCarbonRights: 140000, + validation: 50000, + implementationLabor: 100000, + monitoring: 300000, + maintenance: 0, + communityBenefitSharingFund: 478377.16913539456, + carbonStandardFees: 5207.763081484753, + baselineReassessment: 120000, + mrv: 300000, + longTermProjectOperatingCost: 528000, + }, + npv: { + capitalExpenditure: 1505525.2721514185, + operationalExpenditure: 998329.0070371614, + totalCost: 2503854.27918858, + feasibilityAnalysis: 50000, + conservationPlanningAndAdmin: 629559.3479745106, + dataCollectionAndFieldCost: 76962.52465483235, + communityRepresentation: 197540.230048551, + blueCarbonProjectPlanning: 288609.4674556213, + establishingCarbonRights: 129504.24821726595, + validation: 44449.81793354574, + implementationLabor: 88899.63586709148, + monitoring: 181226.25950738514, + maintenance: 0, + communityBenefitSharingFund: 252249.32242162884, + carbonStandardFees: 2786.931340270489, + baselineReassessment: 75811.8711249392, + mrv: 167296.40590994002, + longTermProjectOperatingCost: 318958.2167329978, + }, + }, + yearlyBreakdown: [ + { + costName: 'feasibilityAnalysis', + totalCost: -50000, + totalNPV: -50000, + costValues: { + '0': 0, + '1': 0, + '2': 0, + '3': 0, + '4': 0, + '5': 0, + '6': 0, + '7': 0, + '8': 0, + '9': 0, + '10': 0, + '11': 0, + '12': 0, + '13': 0, + '14': 0, + '15': 0, + '16': 0, + '17': 0, + '18': 0, + '19': 0, + '20': 0, + '-4': -50000, + '-3': 0, + '-2': 0, + '-1': 0, + }, + }, + { + costName: 'conservationPlanningAndAdmin', + totalCost: -667066.6666666666, + totalNPV: -629559.3479745106, + costValues: { + '0': 0, + '1': 0, + '2': 0, + '3': 0, + '4': 0, + '5': 0, + '6': 0, + '7': 0, + '8': 0, + '9': 0, + '10': 0, + '11': 0, + '12': 0, + '13': 0, + '14': 0, + '15': 0, + '16': 0, + '17': 0, + '18': 0, + '19': 0, + '20': 0, + '-4': -166766.66666666666, + '-3': -166766.66666666666, + '-2': -166766.66666666666, + '-1': -166766.66666666666, + }, + }, + { + costName: 'dataCollectionAndFieldCost', + totalCost: -80000, + totalNPV: -76962.52465483235, + costValues: { + '0': 0, + '1': 0, + '2': 0, + '3': 0, + '4': 0, + '5': 0, + '6': 0, + '7': 0, + '8': 0, + '9': 0, + '10': 0, + '11': 0, + '12': 0, + '13': 0, + '14': 0, + '15': 0, + '16': 0, + '17': 0, + '18': 0, + '19': 0, + '20': 0, + '-4': -26666.666666666668, + '-3': -26666.666666666668, + '-2': -26666.666666666668, + '-1': 0, + }, + }, + { + costName: 'blueCarbonProjectPlanning', + totalCost: -300000, + totalNPV: -288609.4674556213, + costValues: { + '0': 0, + '1': 0, + '2': 0, + '3': 0, + '4': 0, + '5': 0, + '6': 0, + '7': 0, + '8': 0, + '9': 0, + '10': 0, + '11': 0, + '12': 0, + '13': 0, + '14': 0, + '15': 0, + '16': 0, + '17': 0, + '18': 0, + '19': 0, + '20': 0, + '-4': -100000, + '-3': -100000, + '-2': -100000, + '-1': 0, + }, + }, + { + costName: 'communityRepresentation', + totalCost: -213550, + totalNPV: -197540.230048551, + costValues: { + '0': 0, + '1': 0, + '2': 0, + '3': 0, + '4': 0, + '5': 0, + '6': 0, + '7': 0, + '8': 0, + '9': 0, + '10': 0, + '11': 0, + '12': 0, + '13': 0, + '14': 0, + '15': 0, + '16': 0, + '17': 0, + '18': 0, + '19': 0, + '20': 0, + '-4': 0, + '-3': -71183.33333333333, + '-2': -71183.33333333333, + '-1': -71183.33333333333, + }, + }, + { + costName: 'establishingCarbonRights', + totalCost: -140000, + totalNPV: -129504.24821726595, + costValues: { + '0': 0, + '1': 0, + '2': 0, + '3': 0, + '4': 0, + '5': 0, + '6': 0, + '7': 0, + '8': 0, + '9': 0, + '10': 0, + '11': 0, + '12': 0, + '13': 0, + '14': 0, + '15': 0, + '16': 0, + '17': 0, + '18': 0, + '19': 0, + '20': 0, + '-4': 0, + '-3': -46666.666666666664, + '-2': -46666.666666666664, + '-1': -46666.666666666664, + }, + }, + { + costName: 'validation', + totalCost: -50000, + totalNPV: -44449.81793354574, + costValues: { + '0': 0, + '1': 0, + '2': 0, + '3': 0, + '4': 0, + '5': 0, + '6': 0, + '7': 0, + '8': 0, + '9': 0, + '10': 0, + '11': 0, + '12': 0, + '13': 0, + '14': 0, + '15': 0, + '16': 0, + '17': 0, + '18': 0, + '19': 0, + '20': 0, + '-4': 0, + '-3': 0, + '-2': 0, + '-1': -50000, + }, + }, + { + costName: 'implementationLabor', + totalCost: -100000, + totalNPV: -88899.63586709148, + costValues: { + '0': 0, + '1': 0, + '2': 0, + '3': 0, + '4': 0, + '5': 0, + '6': 0, + '7': 0, + '8': 0, + '9': 0, + '10': 0, + '11': 0, + '12': 0, + '13': 0, + '14': 0, + '15': 0, + '16': 0, + '17': 0, + '18': 0, + '19': 0, + '20': 0, + '-4': 0, + '-3': 0, + '-2': 0, + '-1': -100000, + }, + }, + { + costName: 'monitoring', + totalCost: -300000, + totalNPV: -181226.25950738514, + costValues: { + '0': 0, + '1': -15000, + '2': -15000, + '3': -15000, + '4': -15000, + '5': -15000, + '6': -15000, + '7': -15000, + '8': -15000, + '9': -15000, + '10': -15000, + '11': -15000, + '12': -15000, + '13': -15000, + '14': -15000, + '15': -15000, + '16': -15000, + '17': -15000, + '18': -15000, + '19': -15000, + '20': -15000, + '-4': 0, + '-3': 0, + '-2': 0, + '-1': 0, + }, + }, + { + costName: 'maintenance', + totalCost: 0, + totalNPV: 0, + costValues: { + '0': 0, + '1': 0, + '2': 0, + '3': 0, + '4': 0, + '5': 0, + '6': 0, + '7': 0, + '8': 0, + '9': 0, + '10': 0, + '11': 0, + '12': 0, + '13': 0, + '14': 0, + '15': 0, + '16': 0, + '17': 0, + '18': 0, + '19': 0, + '20': 0, + '-4': 0, + '-3': 0, + '-2': 0, + '-1': 0, + }, + }, + { + costName: 'communityBenefitSharingFund', + totalCost: -478377.16913539456, + totalNPV: -252249.32242162884, + costValues: { + '0': 0, + '1': -3101.320320000044, + '2': -4951.5160414005795, + '3': -6853.590667682226, + '4': -8808.645091381013, + '5': -10817.801034974973, + '6': -12882.20142107311, + '7': -15003.01074892755, + '8': -17181.41547737641, + '9': -19418.624414322498, + '10': -21715.869112862427, + '11': -24074.40427416561, + '12': -26495.508157226817, + '13': -28980.482995600523, + '14': -31530.655421230902, + '15': -34147.37689550309, + '16': -36832.024147625285, + '17': -39585.99962047368, + '18': -42410.73192401404, + '19': -45307.67629643649, + '20': -48278.31507311732, + '-4': 0, + '-3': 0, + '-2': 0, + '-1': 0, + }, + }, + { + costName: 'carbonStandardFees', + totalCost: -5207.763081484753, + totalNPV: -2786.931340270489, + costValues: { + '0': 0, + '1': -40.739840000000584, + '2': -64.08329625600338, + '3': -87.38940298199216, + '4': -110.65821993722336, + '5': -133.88980678532448, + '6': -157.08422309446732, + '7': -180.24152833751594, + '8': -203.3617818921798, + '9': -226.44504304114957, + '10': -249.49137097228606, + '11': -272.5008247787324, + '12': -295.473463459085, + '13': -318.4093459175526, + '14': -341.3085309640828, + '15': -364.17107731454234, + '16': -386.9970435908389, + '17': -409.7864883210964, + '18': -432.53946993977934, + '19': -455.25604678787926, + '20': -477.93627711301974, + '-4': 0, + '-3': 0, + '-2': 0, + '-1': 0, + }, + }, + { + costName: 'baselineReassessment', + totalCost: -120000, + totalNPV: -75811.8711249392, + costValues: { + '0': 0, + '1': 0, + '2': 0, + '3': 0, + '4': 0, + '5': 0, + '6': 0, + '7': 0, + '8': 0, + '9': 0, + '10': -40000, + '11': 0, + '12': 0, + '13': 0, + '14': 0, + '15': 0, + '16': 0, + '17': 0, + '18': 0, + '19': 0, + '20': -40000, + '-4': 0, + '-3': 0, + '-2': 0, + '-1': -40000, + }, + }, + { + costName: 'mrv', + totalCost: -300000, + totalNPV: -167296.40590994002, + costValues: { + '0': 0, + '1': 0, + '2': 0, + '3': 0, + '4': 0, + '5': -75000, + '6': 0, + '7': 0, + '8': 0, + '9': 0, + '10': -75000, + '11': 0, + '12': 0, + '13': 0, + '14': 0, + '15': -75000, + '16': 0, + '17': 0, + '18': 0, + '19': 0, + '20': -75000, + '-4': 0, + '-3': 0, + '-2': 0, + '-1': 0, + }, + }, + { + costName: 'longTermProjectOperatingCost', + totalCost: -528000, + totalNPV: -318958.2167329978, + costValues: { + '0': 0, + '1': -26400, + '2': -26400, + '3': -26400, + '4': -26400, + '5': -26400, + '6': -26400, + '7': -26400, + '8': -26400, + '9': -26400, + '10': -26400, + '11': -26400, + '12': -26400, + '13': -26400, + '14': -26400, + '15': -26400, + '16': -26400, + '17': -26400, + '18': -26400, + '19': -26400, + '20': -26400, + '-4': 0, + '-3': 0, + '-2': 0, + '-1': 0, + }, + }, + { + costName: 'opexTotalCostPlan', + totalCost: -1731584.9322168794, + totalNPV: -998329.0070371614, + costValues: { + '0': 0, + '1': -44542.060160000045, + '2': -46415.59933765658, + '3': -48340.98007066422, + '4': -50319.30331131823, + '5': -127351.6908417603, + '6': -54439.28564416758, + '7': -56583.25227726507, + '8': -58784.777259268594, + '9': -61045.06945736365, + '10': -178365.36048383472, + '11': -65746.90509894435, + '12': -68190.9816206859, + '13': -70698.89234151808, + '14': -73271.96395219499, + '15': -150911.5479728176, + '16': -78619.02119121613, + '17': -81395.78610879477, + '18': -84243.27139395382, + '19': -87162.93234322437, + '20': -205156.25135023033, + '-4': 0, + '-3': 0, + '-2': 0, + '-1': -40000, + }, + }, + { + costName: 'capexTotalCostPlan', + totalCost: -1600616.6666666667, + totalNPV: -1505525.2721514185, + costValues: { + '0': 0, + '1': 0, + '2': 0, + '3': 0, + '4': 0, + '5': 0, + '6': 0, + '7': 0, + '8': 0, + '9': 0, + '10': 0, + '11': 0, + '12': 0, + '13': 0, + '14': 0, + '15': 0, + '16': 0, + '17': 0, + '18': 0, + '19': 0, + '20': 0, + '-4': -343433.3333333333, + '-3': -411283.3333333333, + '-2': -411283.3333333333, + '-1': -434616.6666666667, + }, + }, + ], + }, + input: { + 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', + ecosystem: 'Mangrove', + }, + costInputs: { + feasibilityAnalysis: 50000, + conservationPlanningAndAdmin: 166766.66666666666, + dataCollectionAndFieldCost: 26666.666666666668, + communityRepresentation: 71183.33333333333, + blueCarbonProjectPlanning: 100000, + establishingCarbonRights: 46666.666666666664, + financingCost: 0.05, + validation: 50000, + implementationLaborHybrid: null, + implementationLabor: 100, + monitoring: 15000, + maintenance: 0.0833, + carbonStandardFees: 0.2, + communityBenefitSharingFund: 0.5, + baselineReassessment: 40000, + mrv: 75000, + longTermProjectOperatingCost: 26400, + otherCommunityCashFlow: 'Development', + }, + assumptions: { + verificationFrequency: 5, + baselineReassessmentFrequency: 10, + discountRate: 0.04, + restorationRate: 250, + carbonPriceIncrease: 0.015, + buffer: 0.2, + projectLength: 20, + }, + }, + }); + + const customProject = await testManager + .getDataSource() + .getRepository(CustomProject) + .find(); + + expect(response.status).toBe(HttpStatus.CREATED); + expect(customProject).toHaveLength(1); + expect(customProject[0].projectName).toBe('My custom project'); + }); + }); +}); diff --git a/api/test/integration/custom-projects/custom-projects-snapshot.spec.ts b/api/test/integration/custom-projects/custom-projects-snapshot.spec.ts deleted file mode 100644 index 3604e2eb..00000000 --- a/api/test/integration/custom-projects/custom-projects-snapshot.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -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/api/test/integration/import/import-scorecard.spec.ts b/api/test/integration/import/import-scorecard.spec.ts new file mode 100644 index 00000000..bdd53680 --- /dev/null +++ b/api/test/integration/import/import-scorecard.spec.ts @@ -0,0 +1,80 @@ +import { TestManager } from '../../utils/test-manager'; +import { HttpStatus } from '@nestjs/common'; +import { adminContract } from '@shared/contracts/admin.contract'; +import { ROLES } from '@shared/entities/users/roles.enum'; +import * as path from 'path'; +import * as fs from 'fs'; +import { ProjectScorecard } from '@shared/entities/project-scorecard.entity'; + +describe('Import Tests', () => { + let testManager: TestManager; + let testUserToken: string; + const testFilePath = path.join( + __dirname, + '../../../../data/excel/data_ingestion_project_scorecard.xlsm', + ); + const fileBuffer = fs.readFileSync(testFilePath); + + beforeAll(async () => { + testManager = await TestManager.createTestManager(); + }); + + beforeEach(async () => { + const { jwtToken } = await testManager.setUpTestUser(); + testUserToken = jwtToken; + }); + + afterEach(async () => { + await testManager.clearDatabase(); + }); + + afterAll(async () => { + await testManager.close(); + }); + + describe('Import Auth', () => { + it('should throw an error if no file is sent', async () => { + const response = await testManager + .request() + .post(adminContract.uploadProjectScorecard.path) + .set('Authorization', `Bearer ${testUserToken}`) + .send(); + + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + expect(response.body.errors[0].title).toBe('File is required'); + }); + + it('should throw an error if the user is not an admin', async () => { + const nonAdminUser = await testManager + .mocks() + .createUser({ role: ROLES.PARTNER, email: 'testtt@user.com' }); + + const { jwtToken } = await testManager.logUserIn(nonAdminUser); + + const response = await testManager + .request() + .post(adminContract.uploadProjectScorecard.path) + .set('Authorization', `Bearer ${jwtToken}`) + .attach('file', fileBuffer, 'data_ingestion_WIP.xlsm'); + + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + }); + describe('Import Data', () => { + it('should import project scorecard data from an excel file', async () => { + await testManager.ingestCountries(); + await testManager + .request() + .post(adminContract.uploadProjectScorecard.path) + .set('Authorization', `Bearer ${testUserToken}`) + .attach('file', fileBuffer, 'data_ingestion_project_scorecard.xlsm'); + + const projectScorecard = await testManager + .getDataSource() + .getRepository(ProjectScorecard) + .find(); + + expect(projectScorecard).toHaveLength(208); + }, 30000); + }); +}); diff --git a/admin/Dockerfile b/backoffice/Dockerfile similarity index 61% rename from admin/Dockerfile rename to backoffice/Dockerfile index 728754a6..7e7b4d63 100644 --- a/admin/Dockerfile +++ b/backoffice/Dockerfile @@ -6,6 +6,8 @@ ARG DB_NAME ARG DB_USERNAME ARG DB_PASSWORD ARG API_URL +ARG BACKOFFICE_SESSION_COOKIE_NAME +ARG BACKOFFICE_SESSION_COOKIE_SECRET ENV DB_HOST $DB_HOST ENV DB_PORT $DB_PORT @@ -13,12 +15,15 @@ ENV DB_NAME $DB_NAME ENV DB_USERNAME $DB_USERNAME ENV DB_PASSWORD $DB_PASSWORD ENV API_URL $API_URL +ENV BACKOFFICE_SESSION_COOKIE_NAME $BACKOFFICE_SESSION_COOKIE_NAME +ENV BACKOFFICE_SESSION_COOKIE_SECRET $BACKOFFICE_SESSION_COOKIE_SECRET + WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.json ./ -COPY admin ./admin +COPY backoffice ./backoffice COPY shared ./shared COPY api ./api @@ -32,5 +37,5 @@ RUN pnpm install EXPOSE 1000 -# Comando para ejecutar AdminJS en producción -CMD ["pnpm", "admin:prod"] + +CMD ["pnpm", "backoffice:prod"] diff --git a/backoffice/components/dashboard.tsx b/backoffice/components/dashboard.tsx new file mode 100644 index 00000000..71bccccb --- /dev/null +++ b/backoffice/components/dashboard.tsx @@ -0,0 +1,21 @@ +import { Box, H1, Text } from "@adminjs/design-system"; + +const Dashboard = () => { + return ( + + +

Welcome to Blue Carbon Cost Admin Panel

+ Manage your data effectively and efficiently +
+
+ ); +}; + +export default Dashboard; diff --git a/admin/datasource.ts b/backoffice/datasource.ts similarity index 92% rename from admin/datasource.ts rename to backoffice/datasource.ts index 161ef3d4..05d51f68 100644 --- a/admin/datasource.ts +++ b/backoffice/datasource.ts @@ -1,3 +1,6 @@ +import "reflect-metadata"; +import dotenv from "dotenv"; +dotenv.config({ path: `../shared/config/.env` }); import { DataSource } from "typeorm"; import { User } from "@shared/entities/users/user.entity.js"; import { ApiEventsEntity } from "@api/modules/api-events/api-events.entity.js"; @@ -32,10 +35,13 @@ import { ModelAssumptions } from "@shared/entities/model-assumptions.entity.js"; import { UserUploadCostInputs } from "@shared/entities/users/user-upload-cost-inputs.entity.js"; import { UserUploadRestorationInputs } from "@shared/entities/users/user-upload-restoration-inputs.entity.js"; import { UserUploadConservationInputs } from "@shared/entities/users/user-upload-conservation-inputs.entity.js"; +import { BackOfficeSession } from "@shared/entities/users/backoffice-session.js"; +import { CustomProject } from "@shared/entities/custom-project.entity.js"; // TODO: If we import the COMMON_DATABASE_ENTITIES from shared, we get an error where DataSouce is not set for a given entity export const ADMINJS_ENTITIES = [ User, + CustomProject, UserUploadCostInputs, UserUploadRestorationInputs, UserUploadConservationInputs, @@ -68,6 +74,8 @@ export const ADMINJS_ENTITIES = [ BaseSize, BaseIncrease, ModelAssumptions, + CustomProject, + BackOfficeSession ]; export const dataSource = new DataSource({ @@ -83,4 +91,5 @@ export const dataSource = new DataSource({ process.env.NODE_ENV === "production" ? { rejectUnauthorized: false } : false, -}); + logging: false, +}); \ No newline at end of file diff --git a/admin/index.ts b/backoffice/index.ts similarity index 73% rename from admin/index.ts rename to backoffice/index.ts index df3149f4..176fe8e0 100644 --- a/admin/index.ts +++ b/backoffice/index.ts @@ -1,12 +1,14 @@ import "reflect-metadata"; -import AdminJS, { ComponentLoader } from "adminjs"; +import AdminJS, { BaseAuthProvider, ComponentLoader } from "adminjs"; import AdminJSExpress from "@adminjs/express"; -import express from "express"; +import express, { Request, Response } from "express"; import * as AdminJSTypeorm from "@adminjs/typeorm"; import { dataSource } from "./datasource.js"; +import pg from "pg"; +import connectPgSimple from "connect-pg-simple"; +import session from "express-session"; import { AuthProvider } from "./providers/auth.provider.js"; import { UserResource } from "./resources/users/user.resource.js"; -import { Country } from "@shared/entities/country.entity.js"; import { FeasibilityAnalysisResource } from "./resources/feasability-analysis/feasability-analysis.resource.js"; import { ConservationAndPlanningAdminResource } from "./resources/conservation-and-planning-admin/conservation-and-planning-admin.resource.js"; import { CommunityRepresentationResource } from "./resources/community-representation/community-representation.resource.js"; @@ -37,6 +39,8 @@ import { UserUploadCostInputs } from "@shared/entities/users/user-upload-cost-in import { UserUploadConservationInputs } from "@shared/entities/users/user-upload-conservation-inputs.entity.js"; import { UserUploadRestorationInputs } from "@shared/entities/users/user-upload-restoration-inputs.entity.js"; import { GLOBAL_COMMON_PROPERTIES } from "./resources/common/common.resources.js"; +import { BACKOFFICE_SESSIONS_TABLE } from "@shared/entities/users/backoffice-session.js"; +import { CountryResource } from "./resources/countries/country.resource.js"; AdminJS.registerAdapter({ Database: AdminJSTypeorm.Database, @@ -47,9 +51,28 @@ const PORT = 1000; export const API_URL = process.env.API_URL || "http://localhost:4000"; const componentLoader = new ComponentLoader(); + +const Components = { + Dashboard: componentLoader.add("Dashboard", "./components/dashboard"), +}; const authProvider = new AuthProvider(); const start = async () => { + const PgStore = connectPgSimple(session); + const sessionStore = new PgStore({ + pool: new pg.Pool({ + host: process.env.DB_HOST || "localhost", + user: process.env.DB_USERNAME || "blue-carbon-cost", + password: process.env.DB_PASSWORD || "blue-carbon-cost", + database: process.env.DB_NAME || "blc-dev", + port: 5432, + ssl: + process.env.NODE_ENV === "production" + ? { rejectUnauthorized: false } + : false, + }), + tableName: BACKOFFICE_SESSIONS_TABLE, + }); await dataSource.initialize(); const app = express(); @@ -59,6 +82,14 @@ const start = async () => { }; const admin = new AdminJS({ + branding: { + companyName: "Blue Carbon Cost", + withMadeWithLove: false, + logo: false, + }, + dashboard: { + component: Components.Dashboard, + }, rootPath: "/admin", componentLoader, resources: [ @@ -73,7 +104,7 @@ const start = async () => { }, properties: { ...GLOBAL_COMMON_PROPERTIES, - } + }, }, }, { @@ -86,7 +117,7 @@ const start = async () => { }, properties: { ...GLOBAL_COMMON_PROPERTIES, - } + }, }, }, { @@ -99,7 +130,7 @@ const start = async () => { }, properties: { ...GLOBAL_COMMON_PROPERTIES, - } + }, }, }, ProjectSizeResource, @@ -128,14 +159,7 @@ const start = async () => { BaseSizeResource, BaseIncreaseResource, ModelAssumptionResource, - { - resource: Country, - name: "Country", - options: { - parent: databaseNavigation, - icon: "Globe", - }, - }, + CountryResource, ], locale: { language: "en", @@ -145,18 +169,49 @@ const start = async () => { User: "Users", Country: "Countries", Project: "Projects", + ProjectSize: "Project Sizes", + }, + resources: { + ProjectSize: { + properties: { + countryCode: "Country", + }, + }, }, }, }, }, }); - const adminRouter = AdminJSExpress.buildAuthenticatedRouter(admin, { - provider: authProvider, - cookiePassword: "some-secret", + const customRouter = express.Router(); + // Redirect to the app's login page + customRouter.get("/login", (req, res) => { + res.redirect("/auth/signin"); }); - const router = AdminJSExpress.buildRouter(admin); + const sessionCookieName = process.env + .BACKOFFICE_SESSION_COOKIE_NAME as string; + const sessionCookieSecret = process.env + .BACKOFFICE_SESSION_COOKIE_SECRET as string; + const adminRouter = AdminJSExpress.buildAuthenticatedRouter( + admin, + { + provider: authProvider as BaseAuthProvider, + cookieName: sessionCookieName, + cookiePassword: sessionCookieSecret, + }, + customRouter, + { + store: sessionStore, + secret: sessionCookieSecret, + saveUninitialized: false, + resave: false, + cookie: { + secure: false, + maxAge: undefined, + }, + }, + ); app.use(admin.options.rootPath, adminRouter); diff --git a/admin/package.json b/backoffice/package.json similarity index 69% rename from admin/package.json rename to backoffice/package.json index 1ffbc269..59076955 100644 --- a/admin/package.json +++ b/backoffice/package.json @@ -1,5 +1,5 @@ { - "name": "admin", + "name": "backoffice", "version": "1.0.0", "description": "", "type": "module", @@ -11,9 +11,13 @@ "author": "", "license": "ISC", "dependencies": { + "@adminjs/design-system": "^4.1.1", "@adminjs/express": "^6.1.0", "@adminjs/typeorm": "^5.0.1", "adminjs": "^7.8.13", + "connect-pg-simple": "^10.0.0", + "cookie": "^1.0.2", + "cookie-parser": "^1.4.7", "express": "^4.21.0", "express-formidable": "^1.2.0", "express-session": "^1.18.0", @@ -25,8 +29,13 @@ "typescript": "catalog:" }, "devDependencies": { + "@types/connect-pg-simple": "^7.0.3", + "@types/cookie-parser": "^1.4.8", "@types/express": "^4.17.17", + "@types/express-session": "^1.18.1", "@types/node": "^22.7.4", + "@types/pg": "^8.11.10", + "dotenv": "16.4.5", "ts-node": "^10.9.1", "tsx": "^4.19.1" } diff --git a/admin/providers/auth.provider.ts b/backoffice/providers/auth.provider.ts similarity index 75% rename from admin/providers/auth.provider.ts rename to backoffice/providers/auth.provider.ts index e8850787..9432c7ec 100644 --- a/admin/providers/auth.provider.ts +++ b/backoffice/providers/auth.provider.ts @@ -1,5 +1,5 @@ import { BaseAuthProvider, LoginHandlerOptions } from "adminjs"; - +import type { Response } from "express"; import { ROLES } from "@shared/entities/users/roles.enum.js"; import { UserDto, UserWithAccessToken } from "@shared/dtos/users/user.dto.js"; import { API_URL } from "../index.js"; @@ -32,4 +32,12 @@ export class AuthProvider extends BaseAuthProvider { private isAdmin(user: UserDto) { return user.role === ROLES.ADMIN; } + + override async handleLogout({res}: {res: Response}) { + // Remove auth cookies + res.setHeader('Set-Cookie', [ + `backoffice=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly`, + `next-auth.session-token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly`, + ]); + } } diff --git a/admin/resources/base-increase/base-increase.resource.ts b/backoffice/resources/base-increase/base-increase.resource.ts similarity index 100% rename from admin/resources/base-increase/base-increase.resource.ts rename to backoffice/resources/base-increase/base-increase.resource.ts diff --git a/admin/resources/base-size/base-size.resource.ts b/backoffice/resources/base-size/base-size.resource.ts similarity index 100% rename from admin/resources/base-size/base-size.resource.ts rename to backoffice/resources/base-size/base-size.resource.ts diff --git a/admin/resources/baseline-reassesment/baseline-reassesment.resource.ts b/backoffice/resources/baseline-reassesment/baseline-reassesment.resource.ts similarity index 82% rename from admin/resources/baseline-reassesment/baseline-reassesment.resource.ts rename to backoffice/resources/baseline-reassesment/baseline-reassesment.resource.ts index 4debab13..bac5bd14 100644 --- a/admin/resources/baseline-reassesment/baseline-reassesment.resource.ts +++ b/backoffice/resources/baseline-reassesment/baseline-reassesment.resource.ts @@ -15,6 +15,9 @@ export const BaselineReassessmentResource: ResourceWithOptions = { }, properties: { ...GLOBAL_COMMON_PROPERTIES, + baselineReassessmentCost: { + isVisible: { list: true, show: true, filter: false, edit: true }, + }, }, }, }; diff --git a/admin/resources/blue-carbon-project-planning/blue-carbon-project-planning.resource.ts b/backoffice/resources/blue-carbon-project-planning/blue-carbon-project-planning.resource.ts similarity index 50% rename from admin/resources/blue-carbon-project-planning/blue-carbon-project-planning.resource.ts rename to backoffice/resources/blue-carbon-project-planning/blue-carbon-project-planning.resource.ts index c35a9b8c..6ba4e0a8 100644 --- a/admin/resources/blue-carbon-project-planning/blue-carbon-project-planning.resource.ts +++ b/backoffice/resources/blue-carbon-project-planning/blue-carbon-project-planning.resource.ts @@ -6,29 +6,18 @@ export const BlueCarbonProjectPlanningResource: ResourceWithOptions = { resource: BlueCarbonProjectPlanning, options: { properties: { - id: { - isVisible: { list: false, show: false, edit: false, filter: false }, - }, - country: { - isVisible: { list: true, show: true, edit: true, filter: true }, - }, - inputSelection: { - isVisible: { list: true, show: true, edit: true, filter: true }, - }, + ...GLOBAL_COMMON_PROPERTIES, input1: { - isVisible: { list: true, show: true, edit: true, filter: true }, + isVisible: { show: false, edit: true, filter: false, list: true }, }, input2: { - isVisible: { list: true, show: true, edit: true, filter: true }, + isVisible: { show: false, edit: true, filter: false, list: true }, }, input3: { - isVisible: { list: true, show: true, edit: true, filter: true }, + isVisible: { show: false, edit: true, filter: false, list: true }, }, blueCarbon: { - isVisible: { list: true, show: true, edit: false, filter: true }, - }, - properties: { - ...GLOBAL_COMMON_PROPERTIES, + isVisible: { show: true, edit: true, filter: false, list: true }, }, }, sort: { diff --git a/admin/resources/carbon-estandard-fees/carbon-estandard-fees.resource.ts b/backoffice/resources/carbon-estandard-fees/carbon-estandard-fees.resource.ts similarity index 83% rename from admin/resources/carbon-estandard-fees/carbon-estandard-fees.resource.ts rename to backoffice/resources/carbon-estandard-fees/carbon-estandard-fees.resource.ts index cd17383c..3cdd7a38 100644 --- a/admin/resources/carbon-estandard-fees/carbon-estandard-fees.resource.ts +++ b/backoffice/resources/carbon-estandard-fees/carbon-estandard-fees.resource.ts @@ -15,6 +15,9 @@ export const CarbonStandardFeesResource: ResourceWithOptions = { }, properties: { ...GLOBAL_COMMON_PROPERTIES, + carbonStandardFee: { + isVisible: { list: true, show: true, filter: false, edit: true }, + }, }, }, }; diff --git a/admin/resources/carbon-righs/carbon-rights.resource.ts b/backoffice/resources/carbon-righs/carbon-rights.resource.ts similarity index 83% rename from admin/resources/carbon-righs/carbon-rights.resource.ts rename to backoffice/resources/carbon-righs/carbon-rights.resource.ts index e824c570..f65a8fde 100644 --- a/admin/resources/carbon-righs/carbon-rights.resource.ts +++ b/backoffice/resources/carbon-righs/carbon-rights.resource.ts @@ -15,6 +15,9 @@ export const CarbonRightsResource: ResourceWithOptions = { }, properties: { ...GLOBAL_COMMON_PROPERTIES, + carbonRightsCost: { + isVisible: { list: true, show: true, filter: false, edit: true }, + }, }, }, }; diff --git a/admin/resources/common/common.resources.ts b/backoffice/resources/common/common.resources.ts similarity index 100% rename from admin/resources/common/common.resources.ts rename to backoffice/resources/common/common.resources.ts diff --git a/admin/resources/community-benefit/community-benefit.resource.ts b/backoffice/resources/community-benefit/community-benefit.resource.ts similarity index 83% rename from admin/resources/community-benefit/community-benefit.resource.ts rename to backoffice/resources/community-benefit/community-benefit.resource.ts index 01c7c5ba..21501a62 100644 --- a/admin/resources/community-benefit/community-benefit.resource.ts +++ b/backoffice/resources/community-benefit/community-benefit.resource.ts @@ -15,6 +15,9 @@ export const CommunityBenefitResource: ResourceWithOptions = { }, properties: { ...GLOBAL_COMMON_PROPERTIES, + communityBenefitSharingFund: { + isVisible: { list: true, show: true, filter: false, edit: true }, + }, }, }, }; diff --git a/admin/resources/community-cash-flow/community-cash-flow.resource.ts b/backoffice/resources/community-cash-flow/community-cash-flow.resource.ts similarity index 100% rename from admin/resources/community-cash-flow/community-cash-flow.resource.ts rename to backoffice/resources/community-cash-flow/community-cash-flow.resource.ts diff --git a/admin/resources/community-representation/community-representation.resource.ts b/backoffice/resources/community-representation/community-representation.resource.ts similarity index 84% rename from admin/resources/community-representation/community-representation.resource.ts rename to backoffice/resources/community-representation/community-representation.resource.ts index dbb58a35..1e6b9c5c 100644 --- a/admin/resources/community-representation/community-representation.resource.ts +++ b/backoffice/resources/community-representation/community-representation.resource.ts @@ -15,6 +15,9 @@ export const CommunityRepresentationResource: ResourceWithOptions = { }, properties: { ...GLOBAL_COMMON_PROPERTIES, + liaisonCost: { + isVisible: { list: true, show: true, filter: false, edit: true }, + }, }, }, }; diff --git a/admin/resources/conservation-and-planning-admin/conservation-and-planning-admin.resource.ts b/backoffice/resources/conservation-and-planning-admin/conservation-and-planning-admin.resource.ts similarity index 76% rename from admin/resources/conservation-and-planning-admin/conservation-and-planning-admin.resource.ts rename to backoffice/resources/conservation-and-planning-admin/conservation-and-planning-admin.resource.ts index 4cf58646..9c41fbfb 100644 --- a/admin/resources/conservation-and-planning-admin/conservation-and-planning-admin.resource.ts +++ b/backoffice/resources/conservation-and-planning-admin/conservation-and-planning-admin.resource.ts @@ -5,7 +5,12 @@ import { GLOBAL_COMMON_PROPERTIES } from "../common/common.resources.js"; export const ConservationAndPlanningAdminResource: ResourceWithOptions = { resource: ConservationPlanningAndAdmin, options: { - properties: {...GLOBAL_COMMON_PROPERTIES}, + properties: { + ...GLOBAL_COMMON_PROPERTIES, + planningCost: { + isVisible: { list: true, show: true, edit: true, filter: false }, + }, + }, sort: { sortBy: "planningCost", direction: "desc", diff --git a/admin/resources/emission-factors/emission-factors.resource.ts b/backoffice/resources/countries/country.resource.ts similarity index 53% rename from admin/resources/emission-factors/emission-factors.resource.ts rename to backoffice/resources/countries/country.resource.ts index a3aef66c..4b252d77 100644 --- a/admin/resources/emission-factors/emission-factors.resource.ts +++ b/backoffice/resources/countries/country.resource.ts @@ -1,19 +1,23 @@ import { ResourceWithOptions } from "adminjs"; -import { EmissionFactors } from "@shared/entities/carbon-inputs/emission-factors.entity.js"; import { GLOBAL_COMMON_PROPERTIES } from "../common/common.resources.js"; -export const EmissionFactorsResource: ResourceWithOptions = { - resource: EmissionFactors, +import { Country } from "@shared/entities/country.entity.js"; + +export const CountryResource: ResourceWithOptions = { + resource: Country, options: { + properties: { + ...GLOBAL_COMMON_PROPERTIES, + geometry: { + isVisible: { list: false, edit: false, show: false, filter: false }, + }, + }, sort: { - sortBy: "tierSelector", + sortBy: "name", direction: "asc", }, navigation: { name: "Data Management", icon: "Database", }, - properties: { - ...GLOBAL_COMMON_PROPERTIES, - }, }, }; diff --git a/admin/resources/data-collection-and-field-cost/data-collection-and-field-cost.resource.ts b/backoffice/resources/data-collection-and-field-cost/data-collection-and-field-cost.resource.ts similarity index 85% rename from admin/resources/data-collection-and-field-cost/data-collection-and-field-cost.resource.ts rename to backoffice/resources/data-collection-and-field-cost/data-collection-and-field-cost.resource.ts index 8bef62af..cfceb2de 100644 --- a/admin/resources/data-collection-and-field-cost/data-collection-and-field-cost.resource.ts +++ b/backoffice/resources/data-collection-and-field-cost/data-collection-and-field-cost.resource.ts @@ -15,6 +15,9 @@ export const DataCollectionAndFieldCostResource: ResourceWithOptions = { }, properties: { ...GLOBAL_COMMON_PROPERTIES, + fieldCost: { + isVisible: { list: true, show: true, filter: false, edit: true }, + }, }, }, }; diff --git a/admin/resources/ecosystem-loss/ecosystem-loss.resource.ts b/backoffice/resources/ecosystem-loss/ecosystem-loss.resource.ts similarity index 82% rename from admin/resources/ecosystem-loss/ecosystem-loss.resource.ts rename to backoffice/resources/ecosystem-loss/ecosystem-loss.resource.ts index a0e4f771..eb2496a6 100644 --- a/admin/resources/ecosystem-loss/ecosystem-loss.resource.ts +++ b/backoffice/resources/ecosystem-loss/ecosystem-loss.resource.ts @@ -14,6 +14,9 @@ export const EcosystemLossResource: ResourceWithOptions = { }, properties: { ...GLOBAL_COMMON_PROPERTIES, + ecosystemLossRate: { + isVisible: { list: true, show: true, filter: false, edit: true }, + }, }, }, }; diff --git a/backoffice/resources/emission-factors/emission-factors.resource.ts b/backoffice/resources/emission-factors/emission-factors.resource.ts new file mode 100644 index 00000000..ce199c81 --- /dev/null +++ b/backoffice/resources/emission-factors/emission-factors.resource.ts @@ -0,0 +1,37 @@ +import { ResourceWithOptions } from "adminjs"; +import { EmissionFactors } from "@shared/entities/carbon-inputs/emission-factors.entity.js"; +import { GLOBAL_COMMON_PROPERTIES } from "../common/common.resources.js"; +export const EmissionFactorsResource: ResourceWithOptions = { + resource: EmissionFactors, + options: { + sort: { + sortBy: "tierSelector", + direction: "asc", + }, + navigation: { + name: "Data Management", + icon: "Database", + }, + properties: { + ...GLOBAL_COMMON_PROPERTIES, + emissionFactor: { + isVisible: { show: true, edit: true, list: true, filter: false }, + }, + AGB: { + isVisible: { show: true, edit: true, list: true, filter: false }, + }, + SOC: { + isVisible: { show: true, edit: true, list: true, filter: false }, + }, + global: { + isVisible: { show: true, edit: true, list: true, filter: false }, + }, + t2CountrySpecificAGB: { + isVisible: { show: true, edit: true, list: true, filter: false }, + }, + t2CountrySpecificSOC: { + isVisible: { show: true, edit: true, list: true, filter: false }, + }, + }, + }, +}; diff --git a/admin/resources/feasability-analysis/feasability-analysis.resource.ts b/backoffice/resources/feasability-analysis/feasability-analysis.resource.ts similarity index 84% rename from admin/resources/feasability-analysis/feasability-analysis.resource.ts rename to backoffice/resources/feasability-analysis/feasability-analysis.resource.ts index 1b419ce9..1932ef4a 100644 --- a/admin/resources/feasability-analysis/feasability-analysis.resource.ts +++ b/backoffice/resources/feasability-analysis/feasability-analysis.resource.ts @@ -15,6 +15,9 @@ export const FeasibilityAnalysisResource: ResourceWithOptions = { }, properties: { ...GLOBAL_COMMON_PROPERTIES, + analysisCost: { + isVisible: { list: true, show: true, edit: true, filter: false }, + }, }, }, }; diff --git a/admin/resources/financing-cost/financing-cost.resource.ts b/backoffice/resources/financing-cost/financing-cost.resource.ts similarity index 82% rename from admin/resources/financing-cost/financing-cost.resource.ts rename to backoffice/resources/financing-cost/financing-cost.resource.ts index 1d97f2e1..98366465 100644 --- a/admin/resources/financing-cost/financing-cost.resource.ts +++ b/backoffice/resources/financing-cost/financing-cost.resource.ts @@ -14,6 +14,9 @@ export const FinancingCostResource: ResourceWithOptions = { }, properties: { ...GLOBAL_COMMON_PROPERTIES, + financingCostCapexPercent: { + isVisible: { list: true, show: true, filter: false, edit: true }, + }, }, }, }; diff --git a/admin/resources/implementation-labor-cost/implementation-labor-cost.resource.ts b/backoffice/resources/implementation-labor-cost/implementation-labor-cost.resource.ts similarity index 64% rename from admin/resources/implementation-labor-cost/implementation-labor-cost.resource.ts rename to backoffice/resources/implementation-labor-cost/implementation-labor-cost.resource.ts index c458bb8f..3e259d17 100644 --- a/admin/resources/implementation-labor-cost/implementation-labor-cost.resource.ts +++ b/backoffice/resources/implementation-labor-cost/implementation-labor-cost.resource.ts @@ -15,6 +15,15 @@ export const ImplementationLaborCostResource: ResourceWithOptions = { }, properties: { ...GLOBAL_COMMON_PROPERTIES, + plantingCost: { + isVisible: { list: true, show: true, filter: false, edit: true }, + }, + hybridCost: { + isVisible: { list: true, show: true, filter: false, edit: true }, + }, + hydrologyCost: { + isVisible: { list: true, show: true, filter: false, edit: true }, + }, }, }, }; diff --git a/admin/resources/long-term-project-operating/long-term-project-operating.resource.ts b/backoffice/resources/long-term-project-operating/long-term-project-operating.resource.ts similarity index 83% rename from admin/resources/long-term-project-operating/long-term-project-operating.resource.ts rename to backoffice/resources/long-term-project-operating/long-term-project-operating.resource.ts index f733d4b1..09f68a96 100644 --- a/admin/resources/long-term-project-operating/long-term-project-operating.resource.ts +++ b/backoffice/resources/long-term-project-operating/long-term-project-operating.resource.ts @@ -14,6 +14,9 @@ export const LongTermProjectOperatingResource: ResourceWithOptions = { }, properties: { ...GLOBAL_COMMON_PROPERTIES, + longTermProjectOperatingCost: { + isVisible: { list: true, show: true, filter: false, edit: true }, + }, }, }, }; diff --git a/admin/resources/maintenance/maintenance.resource.ts b/backoffice/resources/maintenance/maintenance.resource.ts similarity index 83% rename from admin/resources/maintenance/maintenance.resource.ts rename to backoffice/resources/maintenance/maintenance.resource.ts index 0aa13da9..21628570 100644 --- a/admin/resources/maintenance/maintenance.resource.ts +++ b/backoffice/resources/maintenance/maintenance.resource.ts @@ -14,6 +14,9 @@ export const MaintenanceResource: ResourceWithOptions = { }, properties: { ...GLOBAL_COMMON_PROPERTIES, + monitoringCost: { + isVisible: { show: true, edit: true, list: true, filter: false }, + }, }, }, }; diff --git a/admin/resources/model-assumptions/model-assumptions.resource.ts b/backoffice/resources/model-assumptions/model-assumptions.resource.ts similarity index 84% rename from admin/resources/model-assumptions/model-assumptions.resource.ts rename to backoffice/resources/model-assumptions/model-assumptions.resource.ts index cebb3307..87b07aac 100644 --- a/admin/resources/model-assumptions/model-assumptions.resource.ts +++ b/backoffice/resources/model-assumptions/model-assumptions.resource.ts @@ -15,6 +15,9 @@ export const ModelAssumptionResource: ResourceWithOptions = { }, properties: { ...GLOBAL_COMMON_PROPERTIES, + name: { + isVisible: { list: true, show: true, filter: true, edit: false }, + }, }, }, }; diff --git a/admin/resources/monitoring-cost/monitoring-cost.resource.ts b/backoffice/resources/monitoring-cost/monitoring-cost.resource.ts similarity index 83% rename from admin/resources/monitoring-cost/monitoring-cost.resource.ts rename to backoffice/resources/monitoring-cost/monitoring-cost.resource.ts index 0c62dfa3..bbf093e7 100644 --- a/admin/resources/monitoring-cost/monitoring-cost.resource.ts +++ b/backoffice/resources/monitoring-cost/monitoring-cost.resource.ts @@ -15,6 +15,9 @@ export const MonitoringCostResource: ResourceWithOptions = { }, properties: { ...GLOBAL_COMMON_PROPERTIES, + monitoringCost: { + isVisible: { list: true, show: true, filter: false, edit: true }, + }, }, }, }; diff --git a/admin/resources/mrv/mrv.resource.ts b/backoffice/resources/mrv/mrv.resource.ts similarity index 82% rename from admin/resources/mrv/mrv.resource.ts rename to backoffice/resources/mrv/mrv.resource.ts index eeb005e6..dab640f8 100644 --- a/admin/resources/mrv/mrv.resource.ts +++ b/backoffice/resources/mrv/mrv.resource.ts @@ -14,6 +14,9 @@ export const MRVResource: ResourceWithOptions = { }, properties: { ...GLOBAL_COMMON_PROPERTIES, + mrvCost: { + isVisible: { list: true, show: true, filter: false, edit: true }, + }, }, }, }; diff --git a/admin/resources/project-size/project-size.resource.ts b/backoffice/resources/project-size/project-size.resource.ts similarity index 75% rename from admin/resources/project-size/project-size.resource.ts rename to backoffice/resources/project-size/project-size.resource.ts index 43c44a24..b32671f2 100644 --- a/admin/resources/project-size/project-size.resource.ts +++ b/backoffice/resources/project-size/project-size.resource.ts @@ -14,6 +14,11 @@ export const ProjectSizeResource: ResourceWithOptions = { }, properties: { ...GLOBAL_COMMON_PROPERTIES, + sizeHa: { + type: "number", + isVisible: { list: true, show: true, edit: true, filter: false }, + description: "Size in hectares", + }, }, }, }; diff --git a/backoffice/resources/projects/projects.resource.ts b/backoffice/resources/projects/projects.resource.ts new file mode 100644 index 00000000..184e72ad --- /dev/null +++ b/backoffice/resources/projects/projects.resource.ts @@ -0,0 +1,39 @@ +import { ResourceWithOptions } from "adminjs"; +import { Project } from "@shared/entities/projects.entity.js"; +import { GLOBAL_COMMON_PROPERTIES } from "../common/common.resources.js"; + +export const ProjectsResource: ResourceWithOptions = { + resource: Project, + options: { + properties: { + ...GLOBAL_COMMON_PROPERTIES, + projectSize: { + isVisible: { list: true, show: true, edit: true, filter: false }, + }, + abatementPotential: { + isVisible: { list: true, show: true, edit: true, filter: false }, + }, + totalCostNPV: { + isVisible: { list: true, show: true, edit: true, filter: false }, + }, + totalCost: { + isVisible: { list: true, show: true, edit: true, filter: false }, + }, + costPerTCO2eNPV: { + isVisible: { list: true, show: true, edit: true, filter: false }, + }, + costPerTCO2e: { + isVisible: { list: true, show: true, edit: true, filter: false }, + }, + }, + + sort: { + sortBy: "projectName", + direction: "asc", + }, + navigation: { + name: "Data Management", + icon: "Database", + }, + }, +}; diff --git a/admin/resources/restorable-land/restorable-land.resource.ts b/backoffice/resources/restorable-land/restorable-land.resource.ts similarity index 83% rename from admin/resources/restorable-land/restorable-land.resource.ts rename to backoffice/resources/restorable-land/restorable-land.resource.ts index a82ec46c..4fdf8db3 100644 --- a/admin/resources/restorable-land/restorable-land.resource.ts +++ b/backoffice/resources/restorable-land/restorable-land.resource.ts @@ -14,6 +14,9 @@ export const RestorableLandResource: ResourceWithOptions = { }, properties: { ...GLOBAL_COMMON_PROPERTIES, + restorableLand: { + isVisible: { list: true, show: true, filter: false, edit: true }, + }, }, }, }; diff --git a/admin/resources/sequestration-rate/sequestration-rate.resource.ts b/backoffice/resources/sequestration-rate/sequestration-rate.resource.ts similarity index 56% rename from admin/resources/sequestration-rate/sequestration-rate.resource.ts rename to backoffice/resources/sequestration-rate/sequestration-rate.resource.ts index c05368ba..ec06939f 100644 --- a/admin/resources/sequestration-rate/sequestration-rate.resource.ts +++ b/backoffice/resources/sequestration-rate/sequestration-rate.resource.ts @@ -12,6 +12,17 @@ export const SequestrationRateResource: ResourceWithOptions = { name: "Data Management", icon: "Database", }, - properties: GLOBAL_COMMON_PROPERTIES, + properties: { + ...GLOBAL_COMMON_PROPERTIES, + tier1Factor: { + isVisible: { list: false, show: true, filter: false, edit: true }, + }, + tier2Factor: { + isVisible: { list: false, show: true, filter: false, edit: true }, + }, + sequestrationRate: { + isVisible: { list: true, show: true, filter: false, edit: true }, + }, + }, }, }; diff --git a/admin/resources/users/user.actions.ts b/backoffice/resources/users/user.actions.ts similarity index 100% rename from admin/resources/users/user.actions.ts rename to backoffice/resources/users/user.actions.ts diff --git a/admin/resources/users/user.resource.ts b/backoffice/resources/users/user.resource.ts similarity index 100% rename from admin/resources/users/user.resource.ts rename to backoffice/resources/users/user.resource.ts diff --git a/admin/resources/validation-cost/validation-cost.resource.ts b/backoffice/resources/validation-cost/validation-cost.resource.ts similarity index 83% rename from admin/resources/validation-cost/validation-cost.resource.ts rename to backoffice/resources/validation-cost/validation-cost.resource.ts index d140c7e6..13df8322 100644 --- a/admin/resources/validation-cost/validation-cost.resource.ts +++ b/backoffice/resources/validation-cost/validation-cost.resource.ts @@ -14,6 +14,9 @@ export const ValidationCostResource: ResourceWithOptions = { }, properties: { ...GLOBAL_COMMON_PROPERTIES, + validationCost: { + isVisible: { list: true, show: true, edit: true, filter: false }, + }, }, }, }; diff --git a/admin/tsconfig.json b/backoffice/tsconfig.json similarity index 100% rename from admin/tsconfig.json rename to backoffice/tsconfig.json diff --git a/client/package.json b/client/package.json index ce79b8ee..af4d39cf 100644 --- a/client/package.json +++ b/client/package.json @@ -44,6 +44,7 @@ "next-auth": "4.24.8", "nuqs": "2.0.4", "react": "^18", + "react-country-flag": "^3.1.0", "react-dom": "^18", "react-dropzone": "^14.3.5", "react-map-gl": "7.1.7", diff --git a/client/src/app/auth/api/[...nextauth]/config.ts b/client/src/app/auth/api/[...nextauth]/config.ts index a7672551..f68bbb0e 100644 --- a/client/src/app/auth/api/[...nextauth]/config.ts +++ b/client/src/app/auth/api/[...nextauth]/config.ts @@ -1,3 +1,4 @@ +import { cookies } from "next/headers"; import { UserWithAccessToken } from "@shared/dtos/users/user.dto"; import { LogInSchema } from "@shared/schemas/auth/login.schema"; import type { @@ -36,7 +37,6 @@ export const config = { let access: UserWithAccessToken | null = null; const { email, password } = await LogInSchema.parseAsync(credentials); - const response = await client.auth.login.mutation({ body: { email, @@ -44,6 +44,21 @@ export const config = { }, }); + // Check if adminjs was set in the response + const setCookieHeaders = response.headers.get("set-cookie"); + if (setCookieHeaders !== null) { + const [cookieName, cookieValue] = decodeURIComponent(setCookieHeaders) + .split(";")[0] + .split("="); + + const cookieStore = cookies(); + cookieStore.set(cookieName, cookieValue, { + path: "/", + sameSite: "lax", + httpOnly: true, + }); + } + if (response.status === 201) { access = response.body; } diff --git a/client/src/app/projects/[id]/page.tsx b/client/src/app/projects/[id]/page.tsx new file mode 100644 index 00000000..c85b17fe --- /dev/null +++ b/client/src/app/projects/[id]/page.tsx @@ -0,0 +1,5 @@ +import CustomProject from "@/containers/projects/custom-project"; + +export default function CustomProjectPage() { + return ; +} diff --git a/client/src/app/projects/[id]/store.ts b/client/src/app/projects/[id]/store.ts new file mode 100644 index 00000000..9eb5bf4e --- /dev/null +++ b/client/src/app/projects/[id]/store.ts @@ -0,0 +1,18 @@ +import { atom } from "jotai"; +import { parseAsStringLiteral, useQueryState } from "nuqs"; + +import { CASH_FLOW_VIEWS } from "@/containers/projects/custom-project/annual-project-cash-flow/header/tabs"; + +export const projectsUIState = atom<{ + projectSummaryOpen: boolean; +}>({ + projectSummaryOpen: false, +}); +export const showCostDetailsAtom = atom(false); + +export function useProjectCashFlowView() { + return useQueryState( + "cashflow", + parseAsStringLiteral(CASH_FLOW_VIEWS).withDefault("chart"), + ); +} diff --git a/client/src/components/icons/file-edit.tsx b/client/src/components/icons/file-edit.tsx new file mode 100644 index 00000000..7a1b8d35 --- /dev/null +++ b/client/src/components/icons/file-edit.tsx @@ -0,0 +1,26 @@ +import { SVGProps } from "react"; + +const FileEdit = (props: SVGProps) => { + return ( + + + + + + ); +}; + +export default FileEdit; diff --git a/client/src/components/ui/currency.tsx b/client/src/components/ui/currency.tsx new file mode 100644 index 00000000..5a6cd1b4 --- /dev/null +++ b/client/src/components/ui/currency.tsx @@ -0,0 +1,52 @@ +import { FC } from "react"; + +import { formatCurrency } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +interface CurrencyProps { + /** The numeric value to format as currency */ + value: number; + /** + * Intl.NumberFormat options to customize the currency formatting + * @default { style: "currency", currency: "USD" } + * Override these defaults by passing your own options: + * - currency: Change currency code (e.g. "EUR", "GBP") + * - minimumFractionDigits: Min decimal places + * - maximumFractionDigits: Max decimal places + * - notation: "standard" | "compact" for abbreviated large numbers + */ + options?: Intl.NumberFormatOptions; + /** Optional CSS classes to apply to the wrapper span */ + className?: HTMLSpanElement["className"]; + /** + * Controls the styling of the currency symbol + * @default false - Currency symbol will be smaller, aligned top with muted color + * When true - Currency symbol will have the same styling as the number + */ + plainSymbol?: boolean; +} + +const Currency: FC = ({ + value, + options = {}, + className, + plainSymbol, +}) => { + return ( + + {formatCurrency(value, options)} + + ); +}; + +export default Currency; diff --git a/client/src/components/ui/dialog.tsx b/client/src/components/ui/dialog.tsx index a62d3271..1a8b3919 100644 --- a/client/src/components/ui/dialog.tsx +++ b/client/src/components/ui/dialog.tsx @@ -39,7 +39,7 @@ const DialogContent = React.forwardRef< { + const percentage = (value / total) * 100; + return `${Math.max(percentage, 0)}%`; +}; + +/** + * A responsive graph component that visualizes numerical data as vertical segments + * Has two display modes: + * 1. Standard mode: Shows segments stacked vertically + * 2. Split mode (when leftover is provided): Shows total on left and segments with leftover on right + */ +const Graph: FC = ({ total, leftover, segments }) => { + if (leftover) { + return ( +
+
+
+
+
+ {renderCurrency( + total, + { + notation: "compact", + maximumFractionDigits: 1, + }, + "first-letter:text-secondary-foreground", + )} +
+
+
+
+ {segments.map(({ value, colorClass }) => ( +
+
+
+ {renderCurrency( + value, + { + notation: "compact", + maximumFractionDigits: 1, + }, + "first-letter:text-secondary-foreground", + )} +
+
+
+ ))} +
+
+
+
+ ); + } + + return ( +
+
+ {segments.map(({ value, colorClass }) => ( +
+
+
+ {renderCurrency( + value, + { + notation: "compact", + maximumFractionDigits: 1, + }, + "first-letter:text-secondary-foreground", + )} +
+
+
+ ))} +
+
+ ); +}; + +interface GraphLegendItem { + label: string; + textColor: string; + bgColor: string; +} + +interface GraphLegendProps { + items: GraphLegendItem[]; +} + +const GraphLegend: FC = ({ items }) => { + return ( +
+ {items.map(({ label, textColor, bgColor }) => ( +
+
+
+ {label} +
+
+ ))} +
+ ); +}; + +export { Graph, GraphLegend }; diff --git a/client/src/components/ui/info-button.tsx b/client/src/components/ui/info-button.tsx index 8f777e83..9b68067f 100644 --- a/client/src/components/ui/info-button.tsx +++ b/client/src/components/ui/info-button.tsx @@ -16,8 +16,10 @@ import { export default function InfoButton({ title, children, + className, }: PropsWithChildren<{ title?: string; + className?: string; }>) { return ( @@ -26,7 +28,7 @@ export default function InfoButton({ - + {title && {title}} {children} diff --git a/client/src/components/ui/metric.tsx b/client/src/components/ui/metric.tsx new file mode 100644 index 00000000..1ce02697 --- /dev/null +++ b/client/src/components/ui/metric.tsx @@ -0,0 +1,97 @@ +import { FC, useMemo } from "react"; + +import { cn } from "@/lib/utils"; + +import Currency from "@/components/ui/currency"; + +interface MetricProps { + /** The numeric value to display + * undefined -> renders "-" + */ + value?: number; + /** Unit to display (e.g. "kg", "%", "m²") */ + unit: string; + /** Optional CSS classes to apply to the wrapper */ + className?: string; + /** + * Controls unit position relative to the value + * @default false - Unit appears after the value + * When true - Unit appears before the value + */ + unitBeforeValue?: boolean; + /** + * Render as currency using the Currency component + * @default false - Renders as regular number with unit + * When true - Uses Currency component formatting + */ + isCurrency?: boolean; + /** + * Number format options when not rendering as currency + * @default {} + */ + numberFormatOptions?: Intl.NumberFormatOptions; + /** + * Apply alternative styling to the unit + * @default false - Unit has regular text styling + * When true - Unit has smaller size and muted color + */ + compactUnit?: boolean; +} + +const Metric: FC = ({ + value, + unit, + className, + unitBeforeValue = false, + isCurrency = false, + numberFormatOptions = {}, + compactUnit = false, +}) => { + const ValueElement = useMemo(() => { + if (!value) return null; + + return ( + + {new Intl.NumberFormat("en-US", numberFormatOptions).format(value)} + + ); + }, [numberFormatOptions, value]); + const UnitElement = useMemo( + () => ( + + {unit} + + ), + [compactUnit, unit], + ); + + if (!value) return -; + + if (isCurrency) { + return ( + + ); + } + + return ( + + {unitBeforeValue ? ( + <> + {UnitElement} + {ValueElement} + + ) : ( + <> + {ValueElement} + {UnitElement} + + )} + + ); +}; + +export default Metric; diff --git a/client/src/components/ui/sheet.tsx b/client/src/components/ui/sheet.tsx index 4ba64726..d9d416da 100644 --- a/client/src/components/ui/sheet.tsx +++ b/client/src/components/ui/sheet.tsx @@ -3,8 +3,8 @@ import * as React from "react"; import * as SheetPrimitive from "@radix-ui/react-dialog"; -import { Cross2Icon } from "@radix-ui/react-icons"; import { cva, type VariantProps } from "class-variance-authority"; +import { XIcon } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -66,7 +66,7 @@ const SheetContent = React.forwardRef< {...props} > - + Close {children} diff --git a/client/src/constants/tooltip.tsx b/client/src/constants/tooltip.tsx new file mode 100644 index 00000000..f4421715 --- /dev/null +++ b/client/src/constants/tooltip.tsx @@ -0,0 +1,703 @@ +export const OVERVIEW = { + SCORECARD_RATING: + "The individual non-economic scores, in addition to the economic feasibility and abatement potential, are weighted using the weights on the left to an overall score per project", + COST: "Cost per tCO2e (incl. CAPEX and OPEX)", + + ABATEMENT_POTENTIAL: + "Estimation of the total amount of CO2e abatement that is expected during the life of the project. Used to determine whether the scale justifies the development costs", + TOTAL_COST: "Total cost (incl. CAPEX and OPEX)", +}; + +export const SCORECARD_PRIORITIZATION = { + FINANCIAL_FEASIBILITY: + "Evaluation of the forecasted costs, revenues, and potential break-even price for carbon credits", + LEGAL_FEASIBILITY: + "Evaluation of whether a country has the legal protection, government infrastructure, and political support that is required for a project to successfully produce carbon credits. Focus will also be on community aspects and benefits for community", + IMPLEMENTATION_FEASIBILITY: + "Assessment of the permanence risk a project faces due to deforestation and natural disasters. Used to determine whether a project will achieve the estimated abatement and approval for credit issuance", + SOCIAL_FEASIBILITY: + "Assessment of the leakage risk a project faces from communities reverting to previous activities that degraded or destroyed ecosystems (e.g., deforestation, walling off shrimp ponds, etc.)", + SECURITY_FEASIBILITY: + "Assessment of the safety threat to individuals entering the country. Used to determine the physical risk posed to on-the-ground teams", + AVAILABILITY_OF_EXPERIENCED_LABOR: + "Assessment of whether a country has a pre-existing labor pool with experience in conservation or restoration work, based on the number of blue carbon or AFOLU carbon projects completed or in development", + AVAILABILITY_OF_ALTERNATIVE_FUNDING: + "Assessment of the possibility a project could access revenues outside of carbon credits (e.g., grants, biodiversity credits, resilience credits, etc.) to cover gaps between costs and carbon pricing", + COASTAL_PROTECTION_BENEFIT: + "Estimation of a project's ability to reduce community risk through improved coastal resilience, to inform likelihood of achieving higher credit price", + BIODIVERSITY_BENEFIT: + "Estimation of a project's impact on biodiversity, to inform likelihood of achieving higher credit price", + ABATEMENT_POTENTIAL: + "Estimation of the total amount of CO2e abatement that is expected during the life of the project. Used to determine whether the scale justifies the development costs", +}; + +export const KEY_COSTS = { + TOTAL_COST: "Total cost (incl. CAPEX and OPEX)", + IMPLEMENTATION_LABOR: + "Only applicable to restoration. The costs associated with labor and materials required for rehabilitating the degraded area (hydrology, planting or hybrid)", + COMMUNITY_BENEFIT_SHARING_FUND: + "The creation of a fund to compensate for alternative livelihoods, and opportunity cost. The objective of the fund is to meet the community's socioeconomic and financial priorities, which can be realized through goods, services, infrastructure, and/or cash (e.g., textbooks, desalination plant).", + MONITORING_AND_MAINTENANCE: ( + <> +
    +
  • + Monitoring: The expenses related to individuals moving throughout the + project site to prevent degradation and report necessary + actions/changes. +
  • +
  • + Maintenance: Only applicable to restoration. The costs associated with + the physical upkeep of the original implementation, such as pest + control, removing blockages, and rebuilding small portions. +
  • +
  • +
+ + ), + COMMUNITY_REPRESENTATION: + "Efforts aimed at obtaining community buy-in, including assessing community needs, obtaining free, prior, and informed consent, conducting stakeholder surveys, and providing education about blue carbon.", + CONSERVATION_PLANNING: + "Activities in the project start-up phase like project management, vendor coordination, fundraising, research, travel, etc.", + LONG_TERM_OPERATING: + "The expenses related to project oversight, vendor coordination, community engagement, stakeholder management, etc., during the ongoing operating years of the project.", + CARBON_STANDARD_FEES: + "Administrative fees charged by the carbon standard (e.g., Verra).", +}; + +export const FILTERS = { + CONTINENT: + "Continents are displayed based on the inclusion of countries with available data for blue carbon projects within each region.", + COUNTRY: + "Countries have been selected based on the availability of data supporting blue carbon projects.", + ECOSYSTEM: + "Ecosystems are categorized based on their unique coastal habitats that play a critical role in carbon sequestration and ecosystem services. These include mangroves, seagrasses, and salt marshes, each offering distinct environmental and carbon storage benefits.", + ACTIVITY_TYPE: + "Activity refers to the overarching strategy implemented in a blue carbon project to protect or enhance ecosystem health and carbon sequestration. Projects can focus on either Restoration or Conservation:\n\n• Conservation: Aims to maintain existing ecosystems, preventing degradation and preserving their carbon sequestration potential. Conservation is cost-effective and crucial for long-term climate mitigation, offering benefits like avoiding biodiversity loss, ensuring ecosystem resilience, and reducing financial investment compared to restoration. However, proving additionality can be challenging.\n\n• Restoration: Focuses on rehabilitating degraded ecosystems to restore their functionality and enhance carbon capture. While often more resource-intensive, restoration projects are highly visible and impactful. Restoration is implemented through one of three approaches: planting, hydrology, or a hybrid of the two.", + COST: "Total cost (incl. CAPEX and OPEX)", + ABATEMENT_POTENTIAL: + "Estimation of the total amount of CO2e abatement that is expected during the life of the project. Used to determine whether the scale justifies the development costs", +}; + +export const MAP_LEGEND = + "Comparison of the total costs ($/tCO2e) of blue carbon projects with their carbon abatement potential (tCO₂e/yr)"; + +export const PROJECT_DETAILS = { + TOTAL_PROJECT_COST_NPV: + "The total cost represents the Net Present Value (NPV) of all expenses associated with a hypothetical blue carbon project, including both capital expenditures (CAPEX) and operating expenditures (OPEX) but excluding financing costs.", + TOTAL_PROJECT_COST: + "The total cost represents all expenses associated with a hypothetical blue carbon project, including both capital expenditures (CAPEX) and operating expenditures (OPEX) but excluding financing costs.", + LEFTOVER_AFTER_OPEX: "OPEX gap (rounded to nearest million):", + ABATEMENT_POTENTIAL: + "Estimation of the total amount of CO2e abatement that is expected during the life of the project. Used to determine whether the scale justifies the development costs", + OVERALL_SCORE: + "The individual non-economic scores, in addition to the economic feasibility and abatement potential, are weighted to an overall score per project", +}; + +export const CUSTOM_PROJECT = { + COUNTRY: + "Countries have been selected based on the availability of data supporting blue carbon projects.", + PROJECT_SIZE: "", // Empty as no text is shown excel sheet + ECOSYSTEM: + "Ecosystems are categorized based on their unique coastal habitats that play a critical role in carbon sequestration and ecosystem services. These include mangroves, seagrasses, and salt marshes, each offering distinct environmental and carbon storage benefits.", + ACTIVITY_TYPE: ( +
+

+ Activity refers to the overarching strategy implemented in a blue carbon + project to protect or enhance ecosystem health and carbon sequestration. + Projects can focus on either Restoration or Conservation: +

+
    +
  • + Conservation: Aims to maintain existing ecosystems, preventing + degradation and preserving their carbon sequestration potential. + Conservation is cost-effective and crucial for long-term climate + mitigation, offering benefits like avoiding biodiversity loss, + ensuring ecosystem resilience, and reducing financial investment + compared to restoration. However, proving additionality can be + challenging. +
  • +
  • + Restoration: Focuses on rehabilitating degraded ecosystems to restore + their functionality and enhance carbon capture. While often more + resource-intensive, restoration projects are highly visible and + impactful. Restoration is implemented through one of three approaches: + planting, hydrology, or a hybrid of the two. +
  • +
+
+ ), // TSX +}; + +export const RESTORATION_PROJECT_DETAILS = { + ACTIVITY_TYPE: ( +
+

+ Restoration activity type describes the specific methods used in + restoration projects to rehabilitate degraded ecosystems. The Blue + Carbon Cost Tool supports three types of restoration activities: +

+
    +
  • + Planting: Includes activities such as planting seeds, creating + nurseries, and other efforts to regenerate vegetation in degraded + ecosystems. +
  • +
  • + Hydrology: Focuses on repairing and restoring natural water flow and + ecosystem health. This involves tasks like erosion control, + excavation, and building infrastructure such as culverts or + breakwaters. Hydrology projects often require heavy machinery and tend + to be more capital-intensive than planting projects. +
  • +
  • + Hybrid: Combines planting and hydrology techniques for a comprehensive + restoration approach that addresses multiple ecosystem needs. +
  • +
+

+ Each restoration activity type addresses specific challenges and + ecosystem conditions, allowing tailored interventions for maximum + impact. +

+
+ ), + SEQUESTRATION_RATE: ( +
+

+ The sequestration rate used represents the rate at which a blue carbon + project captures and stores carbon dioxide equivalent (CO2e) within its + ecosystem. The tool allows selection from three tiers of sequestration + rate options: +

+
    +
  • + Tier 1 - Global Sequestration Rate: A default value provided by the + IPCC, applicable to all ecosystems. +
  • +
  • + Tier 2 - Country-Specific Sequestration Rate: National-level + sequestration rates, which are more specific but currently available + only for mangroves. +
  • +
  • + Tier 3 - Project-Specific Sequestration Rate: Custom sequestration + rates based on site-specific data, entered directly into the tool. +
  • +
+

Note: only mangroves have Tier 2 default values.

+
    +
  • + Methane (CH4) and nitrous oxide (N2O) emissions are not currently + included in the default sequestration rate values of the model (please + refer to the “Limitations of the tool” section for further details). + However, it is possible to incorporate CH4 and N2O emissions if the + user possesses project-specific data. In such cases, the emissions + should be converted to their respective CO2e before being added to the + dashboard. For instance, if a project removes 0.71 CO2 but introduces + 0.14 tCO2e of CH4 and 0.12 tCO2e of N2O, the net sequestration value + would be 0.45 tCO2e. +
  • +
  • + We assume that all soil organic carbon has been lost post-disturbance. + As such, we do not include emissions reductions from avoided loss of + soil organic carbon. If the user has this data available, it can be + included in the project-specific sequestration rates as described + above. +
  • +
+
+ ), + PROJECT_SPECIFIC_SEQUESTRATION_RATE: + "The project-specific sequestration rate (Tier 3) refers to a customized sequestration rate derived from detailed site-specific data unique to a particular project. This rate provides the most accurate representation of carbon sequestration potential by incorporating local environmental conditions, ecosystem characteristics, and project-specific factors. It must be directly entered into the tool to tailor calculations to the specific circumstances of the project.", + PLANTING_SUCCESS_RATE: + "The planting success rate refers to the percentage of vegetation or trees successfully established and thriving in a reforestation or afforestation project. This metric is critical for assessing the effectiveness of restoration activities and estimating the carbon sequestration potential of the project. A higher planting success rate indicates more robust ecosystem recovery and greater likelihood of achieving the project's environmental and climate objectives.", +}; + +export const CONSERVATION_PROJECT_DETAILS = { + LOSS_RATE_USED: ( +
+

+ The loss rate used represents the percentage of an ecosystem's + carbon stock expected to be lost annually due to degradation or + deforestation. The tool allows selection between: +

+
    +
  • + National average loss rate: Reflects the average rate of ecosystem + loss specific to the country. Note: Only available for mangroves. +
  • +
  • + Global average loss rate: Default values applicable to salt marshes + and seagrass. +
  • +
  • + Project-specific loss rate: A customized rate based on site-specific + data, which can be manually entered for enhanced precision. +
  • +
+

+ While default loss rates do not factor in background recovery rates, + these can be incorporated when using project-specific loss rates. +

+
+ ), + + PROJECT_SPECIFIC_LOSS_RATE: ( +
+

+ The Project-Specific Loss Rate enables the incorporation of customized + data on ecosystem loss tailored to the specific conditions of the + project site. This loss rate reflects the unique degradation or + disturbance factors affecting the project's ecosystem, allowing for + a more accurate representation of carbon sequestration potential. It is + particularly useful when the national or global default loss rates do + not sufficiently capture the specific environmental, economic, or + operational factors influencing the project area. +

+
+ ), + + EMISSION_FACTOR_USED: ( +
+

+ The Emission Factor Used allows the selection of emission factors from + three distinct tiers, providing flexibility based on the level of + available data for the project: +

+
    +
  • + Tier 1: Utilizes global default emission factors, offering a general + yearly estimate per hectare, suitable when local data is unavailable. +
  • +
  • + Tier 2: Uses country-specific values derived from literature sources, + modeling Above-Ground Biomass (AGB) and Soil Organic Carbon (SOC) + emissions separately. Note: Tier 2 default values are only available + for mangrove ecosystems. +
  • +
  • + Tier 3: Enables the use of project-specific emission factors, which + can be entered either as a single value (similar to Tier 1) or as + separate AGB and SOC values (similar to Tier 2), based on the specific + data available for the project. +
  • +
+

+ Note: The model does not currently account for methane (CH4) and nitrous + oxide (N2O) emissions in the default emission factor values. However, + these emissions can be included if project-specific data is available, + following the appropriate conversion to CO2e. +

+
+ ), + + PROCET_SPECIFIC_EMISSIONS_TYPE: ( +
+

+ The Tier 3 - Project-Specific Emissions approach allows for the highest + level of customization and accuracy in estimating emissions. This can be + achieved by either: +

+
    +
  • + Providing a single, consolidated emission factor specific to the + project, which accounts for all relevant sources of emissions, or +
  • +
  • + Separating emissions into Above-Ground Biomass (AGB) and Soil Organic + Carbon (SOC) components, with distinct values entered for each. +
  • +
+

+ This tier relies on project-specific data, offering the most precise + reflection of local conditions and practices. +

+

+ Note: Default values are not available for Tier 3, requiring + comprehensive project data to be entered manually. +

+
+ ), + + EMISSION_FACTOR: ( +
+

+ One emission: Tier 3 allows the use of a project-specific emission + factor, which is entered as a single value in tCO2e per hectare per + year. This approach provides the highest level of precision by + incorporating local data specific to the project site. The emission + factor represents the annual carbon emissions associated with the + project area and can include factors like changes in vegetation or soil + carbon stocks, tailored to the particular conditions of the project. +

+
+ ), + + SOC_EMISSIONS: ( +
+

+ SOC and AGB separately: Tier 3 - Separate AGB and SOC allows for the + entry of project-specific emission factors for both Aboveground Biomass + (AGB) and Soil Organic Carbon (SOC), each expressed in tCO2e per hectare + per year. By separating AGB and SOC, this approach enables a more + detailed and tailored estimate of carbon sequestration and emissions + specific to the project site. The AGB value represents the committed + emissions from aboveground vegetation, such as trees, shrubs, and other + plant matter, while the SOC value accounts for the carbon stored in the + soil. Both of these emission factors are influenced by local conditions, + land use practices, and ecosystem characteristics. Entering these values + separately provides a more precise reflection of the project's + carbon dynamics and allows for a more accurate calculation of overall + emissions reductions or sequestration potential. +

+
+ ), +}; + +export const GENERAL_ASSUMPTIONS = { + CARBON_REVENUES_TO_COVER: ( +
+

+ Carbon Revenues to Cover provides the flexibility to determine whether + carbon revenues should be used to cover only OPEX (Operational + Expenditures) or both CAPEX (Capital Expenditures) and OPEX. This option + allows developers to account for external funding sources such as grants + or philanthropic contributions that may be used to cover a portion of + the costs. +

+

+ Given that CAPEX, which includes start-up and implementation costs, is + typically higher in blue carbon projects, it is generally recommended + that these costs be covered by other funding sources. In contrast, OPEX, + which includes ongoing operational and maintenance costs, can be + sustainably supported by the revenue generated from carbon credits. This + feature provides flexibility based on the funding strategy and the + expected financial structure of the project. +

+
+ ), + + INITIAL_CARBON_PRICE_ASSUMPTIONS: ( +
+

+ Initial Carbon Price Assumptions (in $) sets the default market price + per ton of CO2 equivalent (tCO2e) for carbon + credits. This price is used to estimate potential revenue from carbon + credits, and can be adjusted based on market conditions or projections. +

+
+ ), +}; + +export const ASSUMPTIONS = { + VERIFICATION_FREQUENCY: ( +
+

+ Verification Frequency refers to how often the carbon credits generated + by the project will be verified by a third-party entity. It is typically + set at regular intervals to ensure the project's carbon + sequestration claims are accurate and to issue verified carbon credits + accordingly. +

+
+ ), + + DISCOUNT_RATE: ( +
+

+ The model currently utilizes a fixed discount rate of 4%. However, this + value can be adjusted to incorporate country-specific premiums or other + relevant circumstances. To gain more insights on this topic, we + recommend referring to the "Benchmark discount rates" sheet in + the Carbon Markets pre-feasibility tool, accessible through the Carbon + Markets Community of Practice. +

+
+ ), + + CARBON_PRICE_INCREASE: ( +
+

+ The assumed increase in carbon price (%) does not include inflation, as + the model does not account for inflation or cost increases in its + calculations. +

+
+ ), + + BUFFER: ( +
+

+ When considering carbon credits, it is crucial to account for + non-permanence, leakage, and uncertainty, which are significant factors. + These factors are encompassed within the "buffer" assumption + in the Blue Carbon Cost Tool, where the default value is set at 20%. +

+

+ While modeling specific scenarios, it is valuable to undertake the + exercise of calculating non-permanence, leakage, and uncertainty. +

+
    +
  • + Non-permanence: Verra offers the VCS Non-permanence risk tool, which + can be employed to estimate non-permanence. This tool considers + various risks, including internal factors (e.g., project management, + project longevity), natural elements (e.g., extreme weather events), + and external influences (e.g., land tenure, political aspects). +
  • +
  • + Leakage: Estimating leakage can be challenging. However, it may be + minimal if the project satisfies specific conditions, for example the + project area having been abandoned or previous commercial activities + having been unprofitable. Additionally, inclusion of leakage + mitigation activities (e.g., ecosystem services payments) within the + project can further reduce leakage potential. +
  • +
  • + Uncertainty: The allowable uncertainty is 20% at 90% confidence level + (or 30% of Net Emissions Reductions at 95% confidence level). In cases + where the uncertainty falls below these thresholds, no deduction for + uncertainty would be applicable. More guidance can be found in + Verra's Tidal wetlands and seagrass restoration methodology. In + cases where uncertainty falls above this threshold, you must deduct an + amount equal to the amount that exceeds uncertainty. For example, if + uncertainty is 28% at a 90% confidence level, you must deduct an + additional 8% from your emissions reductions. When using the tool, + this amount should be added to the buffer (in addition to + non-permanence and leakage amounts). +
  • +
+
+ ), + + BASELINE_REASSESSMENT_FREQUENCY: ( +
+

+ Baseline Reassessment Frequency refers to how often the baseline + emissions or sequestration values are reassessed to ensure the + project's ongoing accuracy in estimating carbon impacts. This is + typically done at regular intervals to account for changes in project + conditions or new data, ensuring that the original assumptions remain + valid throughout the project's lifespan. +

+
+ ), + + CONSERVATION_PROJECT_LENGTH: ( +
+

+ Conservation Project Length refers to the duration over which + conservation efforts are implemented and maintained. This includes + activities aimed at preserving and protecting existing ecosystems to + prevent further degradation and enhance carbon sequestration over time. + The length of a conservation project is typically long-term, as it + involves ongoing monitoring and management to ensure ecosystem stability + and carbon storage potential. +

+
+ ), + + RESTORATION_RATE: ( +
+

+ Make sure to adapt the restoration rate depending on what is feasibly + restorable per year. Then adapt the project size according to this rate + and the duration of the restoration activity. For example, if the + reasonable restoration rate is 50 ha / year and you will restore for + five years, your project size will be 250 ha total. +

+
+ ), + + RESTORATION_PROJECT_LENGTH: ( +
+

+ Restoration Project Length refers to the duration required to restore a + degraded ecosystem to a healthier, functional state, including the time + needed for physical interventions (such as planting or hydrological + modifications) and subsequent maintenance. The length can vary depending + on the scale of restoration activities, site conditions, and the time + required for the ecosystem to recover its full carbon sequestration + potential. +

+
+ ), +}; + +export const COST_INPUT_OVERRIDE = { + FEASIBILITY_ANALYSIS: + "The production of a feasibility assessment, evaluating GHG mitigation potential and financial and non-financial considerations (e.g., legal, social).", + CONSERVATION_PLANNING_AND_ADMIN: + "Activities involved in the project start-up phase, such as project management, vendor coordination, fundraising, research, and travel.", + DATA_COLLECTION_AND_FIELD_COSTS: + "The expenses associated with onsite and field sampling to gather necessary data for conservation plan, blue carbon plan, and credit creation (e.g., carbon stock, vegetation and soil characteristics, hydrological data).", + COMMUNITY_REPRESENTATION: + "Efforts aimed at obtaining community buy-in, including assessing community needs, obtaining free, prior, and informed consent, conducting stakeholder surveys, and providing education about blue carbon.", + BLUE_CARBON_PROJECT_PLANNING: + "The preparation of the project design document (PD), which may include potential sea level rise, hydrological or other modeling.", + ESTABLISHING_CARBON_RIGHTS: + "Legal expenses related to clarifying carbon rights, establishing conservation and community agreements, and packaging carbon benefits for legally valid sales.", + VALIDATION: + "The fee or price associated with the validation of the PD (e.g., by Verra).", + IMPLEMENTATION_LABOR: + "Only applicable to restoration. The costs associated with labor and materials required for rehabilitating the degraded area (hydrology, planting or hybrid). Note: Certain countries, ecosystems and activity types don't have implementation labor estimates.", + MONITORING: + "The expenses related to individuals moving throughout the project site to prevent degradation and report necessary actions/changes.", + MAINTENANCE: + "Only applicable to restoration. The costs associated with the physical upkeep of the original implementation, such as pest control, removing blockages, and rebuilding small portions.", + COMMUNITY_BENEFIT_SHARING_FUND: + "The creation of a fund to compensate for alternative livelihoods, and opportunity cost. The objective of the fund is to meet the community's socioeconomic and financial priorities, which can be realized through goods, services, infrastructure, and/or cash (e.g., textbooks, desalination plant).", + CARBON_STANDARD_FEES: + "Administrative fees charged by the carbon standard (e.g., Verra).", + BASELINE_REASSESSMENT: + "The costs associated with a third-party assessment to ensure the initial GHG emission/reduction estimates are accurate and remain so over time.", + MRV: "The costs associated with measuring, reporting, and verifying GHG emissions that occur post-implementation to enable carbon benefit sales through a third party.", + LONG_TERM_PROJECT_OPERATING: + "The expenses related to project oversight, vendor coordination, community engagement, stakeholder management, etc., during the ongoing operating years of the project.", + FINANCING_COST: + "The time, effort, and cost associated with securing financing for the set-up phase of the project.", +}; + +export const CUSTOM_PROJECT_OUTPUTS = { + TOTAL_PROJECT_COST: + "The total financial investment required for the project, including both capital expenditure (CAPEX) and operating expenditure (OPEX), expressed as NPV (Net Present Value).", + LEFTOVER_AFTER_OPEX: + "The remaining net revenue after accounting for all operating expenses (OPEX) associated with the project.", + ANNUAL_PROJECT_CASH_FLOW: + "The net amount of cash generated or consumed by the project on an annual basis, accounting for revenues, CAPEX, and OPEX.", +}; + +export const PROJECT_SUMMARY = { + COST_PER_TCOE_NPV: + "The NPV of the total cost (CAPEX & OPEX, excl. financing cost) divided by the total credits the project will generate.", + COST_PER_HA: + "The NPV of the total cost (CAPEX & OPEX, excl. financing cost) divided by the total ha of the project", + NPV_COVERING_TOTAL_COST: + 'The NPV of the carbon credit revenues subtracted by either the OPEX or the total cost (depending on parameter in "carbon revenues to cover")', + IRR_WHEN_PRICED_TO_COVER_OPEX: + "The internal rate of return (IRR) calculated when carbon credits are priced to only cover the operating expenses (OPEX).", + IRR_WHEN_PRICED_TO_COVER_TOTAL_COST: + "The internal rate of return (IRR) calculated when carbon credits are priced to cover both capital (CAPEX) and operating expenses (OPEX).", + TOTAL_COST_NPV: + "The NPV of the total cost associated with the hypothetical blue carbon project (incl. CAPEX and OPEX, excl. financing cost)", + CAPITAL_EXPENDITURE_NPV: + "The NPV of the CAPEX associated with the hypothetical blue carbon project", + OPERATING_EXPENDITURE_NPV: + "The NPV of the OPEX associated with the hypothetical blue carbon project", + CREDITS_ISSUED: + "The carbon credits issued as part of the project. The buffer has already been subtracted from this total number", + TOTAL_REVENUE_NPV: "The NPV of the carbon credit revenues", + TOTAL_REVENUE_NON_DISCOUNTED: "The non-discounted carbon credit revenues", + FINANCING_COST: + "The financing cost is the time, effort and cost associated with securing financing for the set up (pre-revenue) phase of the project. Calculated as the financing cost assumption (default 5%) multiplied by the non-discounted CAPEX total.", + FUNDING_GAP_NPV: + 'The reverse of the "NPV covering OPEX" or "NPV covering total cost" metric.', + FUNDING_GAP_PER_TCOE_NPV: + 'The reverse of the "NPV covering OPEX" or "NPV covering total cost" metric.', + COMMUNITY_BENEFIT_SHARING_FUND: + "The percentage of the revenues assumed to go back to the community as part of the community benefit sharing fund.", +}; + +export const COST_DETAILS = ( +
+

+ The cost details provide a comprehensive breakdown of the financial + requirements for the project, divided into capital expenditure (CAPEX) and + operating expenditure (OPEX), with values expressed in both total costs + and their Net Present Value (NPV). Each category represents specific + activities or components of the project: +

+ +

+ Total CAPEX: The total one-time costs required to + establish the project. +

+
    +
  • + Feasibility Analysis: The costs for evaluating the GHG mitigation + potential, legal, social, and financial considerations during project + setup. +
  • +
  • + Conservation Planning and Administration: Expenses for planning and + management activities, including vendor coordination, fundraising, + research, and travel during the setup phase. +
  • +
  • + Data Collection and Field Costs: The expenses related to field sampling + for carbon stock, vegetation, soil characteristics, and hydrological + data collection. +
  • +
  • + Community Representation / Liaison: Costs associated with engaging + communities, obtaining informed consent, and conducting stakeholder + surveys. +
  • +
  • + Blue Carbon Project Planning: The preparation of project design + documents, including modeling for sea level rise, hydrology, and + ecosystem impact. +
  • +
  • + Establishing Carbon Rights: Legal costs for defining carbon rights, + establishing community agreements, and enabling valid carbon credit + sales. +
  • +
  • + Validation: Fees for third-party validation of the project design + documentation. +
  • +
  • + Implementation Labor: Costs for restoration-related labor and materials + (if applicable, e.g., planting or hydrological interventions). +
  • +
+ +

+ Total OPEX: The ongoing costs required to maintain and + monitor the project throughout its operational lifespan. +

+
    +
  • + Monitoring: Expenses for ensuring the project site remains intact, with + regular checks to prevent degradation. +
  • +
  • + Maintenance: Costs for maintaining the physical project infrastructure + (if applicable, e.g., in restoration projects). +
  • +
  • + Community Benefit Sharing Fund: A percentage of the project revenues + allocated for community benefits, such as infrastructure, goods, or + cash. +
  • +
  • + Carbon Standard Fees: Administrative fees associated with carbon credit + standards (e.g., registration or issuance). +
  • +
  • + Baseline Reassessment: Costs for periodic re-evaluation of initial GHG + reduction estimates. +
  • +
  • + Measuring, Reporting, and Verification (MRV): Expenses for ongoing + measurement, reporting, and verification of GHG emissions. +
  • +
  • + Long-Term Project Operating: General expenses for continued oversight, + stakeholder engagement, and vendor coordination over the project's + lifetime. +
  • +
+ +

+ Total Project Cost: The sum of all CAPEX and OPEX costs, + expressed in both total value and NPV. The total project cost gives + stakeholders a clear view of financial investment required for the + hypothetical blue carbon project. +

+
+); + +export const ANNUAL_PROJECT_CASHFLOW = + "The Annual Project Cash Flow represents the year-by-year net financial outcome of the project, calculated as the total revenues (primarily from carbon credit sales) minus the annual operating expenditures (OPEX). This metric provides insight into the financial viability and sustainability of the project over its operational lifespan, highlighting when the project is expected to become profitable or break even"; diff --git a/client/src/containers/auth/dialog/index.tsx b/client/src/containers/auth/dialog/index.tsx new file mode 100644 index 00000000..de7aa93d --- /dev/null +++ b/client/src/containers/auth/dialog/index.tsx @@ -0,0 +1,55 @@ +import { FC, useState } from "react"; + +import SignInForm from "@/containers/auth/signin/form"; +import SignUpForm from "@/containers/auth/signup/form"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Separator } from "@/components/ui/separator"; + +interface AuthDialogProps { + dialogTrigger: React.ReactNode; + onSignIn: () => void; +} + +const AuthDialog: FC = ({ dialogTrigger, onSignIn }) => { + const [showSignin, setShowSignin] = useState(true); + + return ( + { + if (!open && !showSignin) setShowSignin(true); + }} + > + {dialogTrigger} + + + Sign in + + {showSignin ? : } + +

+ + {showSignin ? "Don't have an account?" : "Already have an account?"} + + +

+
+
+ ); +}; + +export default AuthDialog; diff --git a/client/src/containers/auth/signin/form/index.tsx b/client/src/containers/auth/signin/form/index.tsx index 856f6996..f68f695d 100644 --- a/client/src/containers/auth/signin/form/index.tsx +++ b/client/src/containers/auth/signin/form/index.tsx @@ -25,7 +25,10 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -const SignInForm: FC = () => { +interface SignInFormProps { + onSignIn?: () => void; +} +const SignInForm: FC = ({ onSignIn }) => { const router = useRouter(); const searchParams = useSearchParams(); const [errorMessage, setErrorMessage] = useState(""); @@ -51,7 +54,11 @@ const SignInForm: FC = () => { }); if (response?.ok) { - router.push(searchParams.get("callbackUrl") ?? "/profile"); + if (onSignIn) { + onSignIn(); + } else { + router.push(searchParams.get("callbackUrl") ?? "/profile"); + } } if (!response?.ok) { @@ -64,7 +71,7 @@ const SignInForm: FC = () => { } })(evt); }, - [form, router, searchParams], + [form, router, searchParams, onSignIn], ); return ( diff --git a/client/src/containers/auth/signup/form/index.tsx b/client/src/containers/auth/signup/form/index.tsx index fdccf5d4..f335f58c 100644 --- a/client/src/containers/auth/signup/form/index.tsx +++ b/client/src/containers/auth/signup/form/index.tsx @@ -89,11 +89,11 @@ const TokenSignUpForm: FC = () => { name="partnerName" render={({ field }) => ( - Partner + Organization diff --git a/client/src/containers/overview/filters/index.tsx b/client/src/containers/overview/filters/index.tsx index a8e4b436..69574bfe 100644 --- a/client/src/containers/overview/filters/index.tsx +++ b/client/src/containers/overview/filters/index.tsx @@ -158,9 +158,9 @@ export default function ProjectsFilters() {

Filters

diff --git a/client/src/containers/overview/project-details/index.tsx b/client/src/containers/overview/project-details/index.tsx index 7c0cf318..279b4888 100644 --- a/client/src/containers/overview/project-details/index.tsx +++ b/client/src/containers/overview/project-details/index.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import { useAtom } from "jotai"; -import { ChevronUp, ChevronDown, Plus, NotebookPen } from "lucide-react"; +import { ChevronUp, ChevronDown, NotebookPen } from "lucide-react"; import { renderCurrency, @@ -63,7 +63,6 @@ const CreateProjectDetails = () => (
); -//////// ScoreIndicator component //////// interface ScoreIndicatorProps { score: "High" | "Medium" | "Low"; className?: string; @@ -382,17 +381,17 @@ export default function ProjectDetails() {

Scorecard ratings

-
- - -
+ {/*
*/} + {/* */} + {/* */} + {/* */} + {/* */} + {/*
*/}
{projectData.scorecard.map((item, index) => ( @@ -428,17 +427,17 @@ export default function ProjectDetails() {

Cost estimates

-
- - -
+ {/*
*/} + {/* */} + {/* */} + {/* */} + {/* */} + {/*
*/}
{projectData.costEstimates.map((estimate) => ( diff --git a/client/src/containers/overview/table/toolbar/index.tsx b/client/src/containers/overview/table/toolbar/index.tsx index e0e23712..1cfcf14d 100644 --- a/client/src/containers/overview/table/toolbar/index.tsx +++ b/client/src/containers/overview/table/toolbar/index.tsx @@ -1,7 +1,108 @@ +import { cn } from "@/lib/utils"; + +import { SCORECARD_PRIORITIZATION, KEY_COSTS } from "@/constants/tooltip"; + import SearchProjectsTable from "@/containers/overview/table/toolbar/search"; import TabsProjectsTable from "@/containers/overview/table/toolbar/table-selector"; import InfoButton from "@/components/ui/info-button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell, +} from "@/components/ui/table"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; + +interface ScorecardMetric { + name: string; + description: string; + weight: number; +} + +interface KeyCost { + name: string; + description: string | keyof typeof KEY_COSTS; +} + +const SCORECARD_METRICS: ScorecardMetric[] = [ + { + name: "Economic feasibility", + description: "FINANCIAL_FEASIBILITY", + weight: 20, + }, + { + name: "Abatement potential", + description: "ABATEMENT_POTENTIAL", + weight: 18, + }, + { name: "Legal feasibility", description: "LEGAL_FEASIBILITY", weight: 12 }, + { + name: "Implementation risk score", + description: "IMPLEMENTATION_FEASIBILITY", + weight: 12, + }, + { name: "Social feasibility", description: "SOCIAL_FEASIBILITY", weight: 12 }, + { + name: "Availability of experienced labor", + description: "AVAILABILITY_OF_EXPERIENCED_LABOR", + weight: 10, + }, + { name: "Security rating", description: "SECURITY_FEASIBILITY", weight: 5 }, + { + name: "Availability of alternative funding", + description: "AVAILABILITY_OF_ALTERNATIVE_FUNDING", + weight: 5, + }, + { + name: "Coastal protection benefit", + description: "COASTAL_PROTECTION_BENEFIT", + weight: 3, + }, + { + name: "Biodiversity benefit", + description: "BIODIVERSITY_BENEFIT", + weight: 3, + }, +]; + +const KEY_COSTS_DATA: KeyCost[] = [ + { + name: "Implementation labor", + description: "IMPLEMENTATION_LABOR", + }, + { + name: "Community benefit sharing fund", + description: "COMMUNITY_BENEFIT_SHARING_FUND", + }, + { + name: "Monitoring and maintenance", + description: "MONITORING_AND_MAINTENANCE", + }, + { + name: "Community representation/liaison", + description: "COMMUNITY_REPRESENTATION", + }, + { + name: "Conservation planning and admin", + description: + "Approximated as the salaries of a project manager and a program coordinator. 20% of the salaries is added to account for meetings/ expenses. 75% of these approximations are considered here and 25% of these costs are applied for community representation/ liaison", + }, + { + name: "Long-term project operating", + description: "LONG_TERM_OPERATING", + }, + { + name: "Carbon standard fees", + description: "CARBON_STANDARD_FEES", + }, +]; + +const TABS_TRIGGER_CLASSES = + "border-b-2 border-transparent transition-colors data-[state=active]:!border-sky-blue-300 data-[state=active]:bg-transparent"; export default function ToolbarProjectsTable() { return ( @@ -9,31 +110,217 @@ export default function ToolbarProjectsTable() {
- -

- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed - vehicula, nunc nec vehicula fermentum, nunc libero bibendum purus, - nec tincidunt libero nunc nec libero. Integer nec libero nec libero - tincidunt tincidunt. Sed vehicula, nunc nec vehicula fermentum, nunc - libero bibendum purus, nec tincidunt libero nunc nec libero. Integer - nec libero nec libero tincidunt tincidunt. -

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed - vehicula, nunc nec vehicula fermentum, nunc libero bibendum purus, - nec tincidunt libero nunc nec libero. Integer nec libero nec libero - tincidunt tincidunt. Sed vehicula, nunc nec vehicula fermentum, nunc - libero bibendum purus, nec tincidunt libero nunc nec libero. Integer - nec libero nec libero tincidunt tincidunt. -

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed - vehicula, nunc nec vehicula fermentum, nunc libero bibendum purus, - nec tincidunt libero nunc nec libero. Integer nec libero nec libero - tincidunt tincidunt. Sed vehicula, nunc nec vehicula fermentum, nunc - libero bibendum purus, nec tincidunt libero nunc nec libero. Integer - nec libero nec libero tincidunt tincidunt. -

+ +
+ +
+ + + General + + + Overview table + + + Scorecard prioritization table + + + Key costs table + + +
+ +
+ +

+ This table offers three distinct views, showcasing example + projects across various countries and activity types. Use + the filters to refine your results, or adjust the selectors + —"Project Size," "Carbon Pricing Type," + and "Cost"—to see different perspectives. +

+
+ + +

+ In addition to economic feasibility and abatement potential, + this table includes{" "} + + qualitative, non-economic scores + + , which may vary by country or ecosystem. Each + project's overall score combines these non-economic + scores with economic feasibility and abatement potential to + give a comprehensive evaluation. These scores add additional + insights for project assessment. +

+
+
+
Low
+
+ Medium +
+
High
+
+
+
+ + + + +
+
+

+ In addition to economic feasibility and abatement + potential, this table includes{" "} + + qualitative, non-economic scores + + , which may vary by country or ecosystem. Each + project's overall score combines these + non-economic scores with economic feasibility and + abatement potential to give a comprehensive + evaluation. These scores add additional insights for + project assessment. +

+

+ Each metric can go from a scale from low to high: +

+
+
+
Low
+
Description of low
+
+
+
Medium
+
Description
+
+
+
High
+
Description
+
+
+
+ + + + + Metric + + Description + + + Weight + + + + + {SCORECARD_METRICS.map((metric) => ( + + + {metric.name} + + + { + SCORECARD_PRIORITIZATION[ + metric.description as keyof typeof SCORECARD_PRIORITIZATION + ] + } + + + {metric.weight} + + + ))} + +
+
+
+
+ + + +
+
+

+ This table provides an overview of the most + significant cost components for typical blue carbon + projects, categorized by country, ecosystem, and + activity. This table enables easy comparison of these + essential cost components. +

+

+ Each metric is color coded depending on the minimum + range for each metric. +

+
+
+
+ Min value + Max value +
+
+
+ + + + + Metric + + Description + + + + + {KEY_COSTS_DATA.map((cost) => ( + + + {cost.name} + + + {typeof cost.description === "string" && + !KEY_COSTS[ + cost.description as keyof typeof KEY_COSTS + ] + ? cost.description + : KEY_COSTS[ + cost.description as keyof typeof KEY_COSTS + ]} + + + ))} + +
+
+ + +
+ +
diff --git a/client/src/containers/overview/table/view/overview/index.tsx b/client/src/containers/overview/table/view/overview/index.tsx index 3d41b2d6..fd009c77 100644 --- a/client/src/containers/overview/table/view/overview/index.tsx +++ b/client/src/containers/overview/table/view/overview/index.tsx @@ -30,9 +30,6 @@ import { } from "@/containers/overview/table/utils"; import { columns } from "@/containers/overview/table/view/overview/columns"; -type filterFields = z.infer; -type sortFields = z.infer; - import { Table, TableBody, @@ -45,6 +42,9 @@ import TablePagination, { PAGINATION_SIZE_OPTIONS, } from "@/components/ui/table-pagination"; +type filterFields = z.infer; +type sortFields = z.infer; + export function OverviewTable() { const [tableView] = useTableView(); const [filters] = useGlobalFilters(); diff --git a/client/src/containers/profile/delete-account/index.tsx b/client/src/containers/profile/delete-account/index.tsx index fa72719d..ca5e4d59 100644 --- a/client/src/containers/profile/delete-account/index.tsx +++ b/client/src/containers/profile/delete-account/index.tsx @@ -33,10 +33,8 @@ const DeleteAccount: FC = () => { }); if (status === 200) { - signOut({ callbackUrl: "/auth/signin" }); - } - - if (status === 400 || status === 401) { + signOut({ callbackUrl: "/auth/signin", redirect: true }); + } else if (status === 400 || status === 401) { toast({ variant: "destructive", description: body.errors?.[0].title, diff --git a/client/src/containers/profile/file-upload/description/index.tsx b/client/src/containers/profile/file-upload/description/index.tsx new file mode 100644 index 00000000..5337a27d --- /dev/null +++ b/client/src/containers/profile/file-upload/description/index.tsx @@ -0,0 +1,70 @@ +import { FC } from "react"; + +import { FileDownIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; + +const downloadFiles = (files: iFile[]) => { + files.forEach((f) => { + const link = document.createElement("a"); + link.href = f.path; + link.download = f.path.split("/").pop() || ""; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }); +}; + +const openFileUploadWindow = () => + document.getElementById("share-information-input")?.click(); + +interface iFile { + name: string; + path: string; +} + +interface FileUploadDescriptionProps { + files: iFile[]; +} + +const FileUploadDescription: FC = ({ files }) => { + return ( + <> +

+ Provide your input on the methodology and data by  + +  the required templates, completing them with the necessary + information, and  + +  them to contribute new insights for evaluation. +

+ +
    + {files.map((f) => ( +
  1. + +
  2. + ))} +
+ + ); +}; + +export default FileUploadDescription; diff --git a/client/src/containers/profile/file-upload/index.tsx b/client/src/containers/profile/file-upload/index.tsx index 8b6a00f4..39dac29a 100644 --- a/client/src/containers/profile/file-upload/index.tsx +++ b/client/src/containers/profile/file-upload/index.tsx @@ -2,7 +2,7 @@ import React, { FC, useCallback, useState } from "react"; import { useDropzone } from "react-dropzone"; -import { FilePlusIcon, XIcon } from "lucide-react"; +import { FileUpIcon, XIcon } from "lucide-react"; import { useSession } from "next-auth/react"; import { client } from "@/lib/query-client"; @@ -13,10 +13,18 @@ import { Card } from "@/components/ui/card"; import { useToast } from "@/components/ui/toast/use-toast"; // Array should be in this order -const REQUIRED_FILES = [ - "carbon-input-template.xlsx", - "cost-input-template.xlsx", +export const TEMPLATE_FILES = [ + { + name: "carbon-input-template.xlsx", + path: "/templates/carbon-input-template.xlsx", + }, + { + name: "cost-input-template.xlsx", + path: "/templates/cost-input-template.xlsx", + }, ]; + +const REQUIRED_FILE_NAMES = TEMPLATE_FILES.map((f) => f.name); const EXCEL_EXTENSIONS = [".xlsx", ".xls"]; const MAX_FILES = 2; @@ -27,7 +35,7 @@ const FileUpload: FC = () => { const onDropAccepted = useCallback( (acceptedFiles: File[]) => { const validFiles = acceptedFiles.filter((file) => - REQUIRED_FILES.includes(file.name), + REQUIRED_FILE_NAMES.includes(file.name), ); if (validFiles.length !== acceptedFiles.length) { @@ -64,7 +72,7 @@ const FileUpload: FC = () => { }; const handleUploadClick = async () => { const fileNames = files.map((file) => file.name); - const missingFiles = REQUIRED_FILES.filter( + const missingFiles = REQUIRED_FILE_NAMES.filter( (name) => !fileNames.includes(name), ); @@ -76,7 +84,7 @@ const FileUpload: FC = () => { } const formData = new FormData(); - const sortedFiles = REQUIRED_FILES.map( + const sortedFiles = REQUIRED_FILE_NAMES.map( (name) => files.find((file) => file.name === name)!, ); @@ -117,19 +125,24 @@ const FileUpload: FC = () => { {...getRootProps()} variant="secondary" className={cn({ - "select-none border-dashed p-10 transition-colors": true, + "select-none border bg-big-stone-950 p-10 transition-colors": true, "bg-card": isDragActive, "cursor-pointer hover:bg-card": files.length < MAX_FILES, "cursor-not-allowed opacity-50": files.length >= MAX_FILES, })} > - +
- +

- {files.length < MAX_FILES - ? "Drop files, or click to upload" - : "You've attached the maximum of 2 files"} + {files.length < MAX_FILES ? ( + <> + Drag and drop the files or  + click to upload + + ) : ( + "You've attached the maximum of 2 files" + )}

diff --git a/client/src/containers/profile/index.tsx b/client/src/containers/profile/index.tsx index e5e4e7eb..1e236aea 100644 --- a/client/src/containers/profile/index.tsx +++ b/client/src/containers/profile/index.tsx @@ -7,16 +7,16 @@ import Link from "next/link"; import { useSetAtom } from "jotai"; import CustomProjects from "@/containers/profile/custom-projects"; -import FileUpload from "@/containers/profile/file-upload"; +import DeleteAccount from "@/containers/profile/delete-account"; +import FileUpload, { TEMPLATE_FILES } from "@/containers/profile/file-upload"; +import FileUploadDescription from "@/containers/profile/file-upload/description"; import ProfileSection from "@/containers/profile/profile-section"; import ProfileSidebar from "@/containers/profile/profile-sidebar"; import { intersectingAtom } from "@/containers/profile/store"; import UserDetails from "@/containers/profile/user-details"; -import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { SidebarTrigger } from "@/components/ui/sidebar"; -import DeleteAccount from "src/containers/profile/delete-account"; const sections = [ { @@ -40,33 +40,9 @@ const sections = [ Component: CustomProjects, }, { - id: "data-upload", - title: "Data upload", - description: ( - <> -

- Download the required templates, fill them in, and upload the - completed files below. -

- -
    -
  1. - -
  2. -
  3. - -
  4. -
- - ), + id: "share-information", + title: "Share information", + description: , Component: FileUpload, }, { diff --git a/client/src/containers/projects/custom-project/annual-project-cash-flow/header/index.tsx b/client/src/containers/projects/custom-project/annual-project-cash-flow/header/index.tsx new file mode 100644 index 00000000..4c18c1b1 --- /dev/null +++ b/client/src/containers/projects/custom-project/annual-project-cash-flow/header/index.tsx @@ -0,0 +1,19 @@ +import { FC } from "react"; + +import Tabs from "@/containers/projects/custom-project/annual-project-cash-flow/header/tabs"; + +import InfoButton from "@/components/ui/info-button"; + +const Header: FC = () => { + return ( +
+

Annual project cash flow

+ +
+ tooltip.content +
+
+ ); +}; + +export default Header; diff --git a/client/src/containers/projects/custom-project/annual-project-cash-flow/header/tabs/index.tsx b/client/src/containers/projects/custom-project/annual-project-cash-flow/header/tabs/index.tsx new file mode 100644 index 00000000..e7cbfb97 --- /dev/null +++ b/client/src/containers/projects/custom-project/annual-project-cash-flow/header/tabs/index.tsx @@ -0,0 +1,39 @@ +import { ChartNoAxesColumnIcon, Table2Icon } from "lucide-react"; + +import { useProjectCashFlowView } from "@/app/projects/[id]/store"; + +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +export const CASH_FLOW_VIEWS = ["chart", "table"] as const; + +export const CASH_FLOW_TABS = [ + { + Icon: , + value: CASH_FLOW_VIEWS[0], + }, + { + Icon: , + value: CASH_FLOW_VIEWS[1], + }, +] as const; + +export default function CashFlowTabs() { + const [view, setView] = useProjectCashFlowView(); + + return ( + { + await setView(v as typeof view); + }} + > + + {CASH_FLOW_TABS.map(({ Icon, value }) => ( + + {Icon} + + ))} + + + ); +} diff --git a/client/src/containers/projects/custom-project/annual-project-cash-flow/index.tsx b/client/src/containers/projects/custom-project/annual-project-cash-flow/index.tsx new file mode 100644 index 00000000..62645ffd --- /dev/null +++ b/client/src/containers/projects/custom-project/annual-project-cash-flow/index.tsx @@ -0,0 +1,15 @@ +import { FC } from "react"; + +import Header from "@/containers/projects/custom-project/annual-project-cash-flow/header"; + +import { Card } from "@/components/ui/card"; + +const AnnualProjectCashFlow: FC = () => { + return ( + +
+ + ); +}; + +export default AnnualProjectCashFlow; diff --git a/client/src/containers/projects/custom-project/cost-details/index.tsx b/client/src/containers/projects/custom-project/cost-details/index.tsx new file mode 100644 index 00000000..5a4f2cc0 --- /dev/null +++ b/client/src/containers/projects/custom-project/cost-details/index.tsx @@ -0,0 +1,37 @@ +import { FC } from "react"; + +import { useAtom } from "jotai"; + +import { showCostDetailsAtom } from "@/app/projects/[id]/store"; + +import CostDetailsParameters from "@/containers/projects/custom-project/cost-details/parameters"; +import CostDetailTable from "@/containers/projects/custom-project/cost-details/table"; + +import InfoButton from "@/components/ui/info-button"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; + +const CostDetails: FC = () => { + const [isVisible, setIsVisible] = useAtom(showCostDetailsAtom); + + return ( + + + + +

Cost details

+ tooltip.content +
+
+ + +
+
+ ); +}; + +export default CostDetails; diff --git a/client/src/containers/projects/custom-project/cost-details/parameters/index.tsx b/client/src/containers/projects/custom-project/cost-details/parameters/index.tsx new file mode 100644 index 00000000..14efdaa6 --- /dev/null +++ b/client/src/containers/projects/custom-project/cost-details/parameters/index.tsx @@ -0,0 +1,78 @@ +import { COST_TYPE_SELECTOR } from "@shared/entities/projects.entity"; + +import { FILTER_KEYS } from "@/app/(overview)/constants"; + +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +const PARAMETERS = [ + { + key: FILTER_KEYS[3], + label: "Cost type", + className: "w-full", + options: [ + { + label: COST_TYPE_SELECTOR.TOTAL, + value: COST_TYPE_SELECTOR.TOTAL, + }, + { + label: COST_TYPE_SELECTOR.NPV, + value: COST_TYPE_SELECTOR.NPV, + }, + ], + }, + { + key: "carbon-price-type", + label: "Carbon pricing type", + className: "w-full", + options: [ + { + label: "Initial carbon price assumption", + value: "mock", + }, + ], + }, +] as const; + +export default function CostDetailsParameters() { + // const handleParameters = async ( + // v: string, + // // parameter: keyof Omit, "keyword">, + // ) => { + // // TODO + // }; + + return ( +
+ {PARAMETERS.map((parameter) => ( +
+ + +
+ ))} +
+ ); +} diff --git a/client/src/containers/projects/custom-project/cost-details/table/columns.tsx b/client/src/containers/projects/custom-project/cost-details/table/columns.tsx new file mode 100644 index 00000000..6ef7cc89 --- /dev/null +++ b/client/src/containers/projects/custom-project/cost-details/table/columns.tsx @@ -0,0 +1,20 @@ +import { createColumnHelper } from "@tanstack/react-table"; + +import { CostItem } from "@/containers/projects/custom-project/cost-details/table"; + +const columnHelper = createColumnHelper(); + +export const columns = [ + columnHelper.accessor("label", { + enableSorting: true, + header: () => Cost estimates, + }), + columnHelper.accessor("value", { + enableSorting: true, + header: () => Cost $/tCo2, + }), + columnHelper.accessor("value", { + enableSorting: true, + header: () => Sensitive analysis, + }), +]; diff --git a/client/src/containers/projects/custom-project/cost-details/table/index.tsx b/client/src/containers/projects/custom-project/cost-details/table/index.tsx new file mode 100644 index 00000000..1b3d938a --- /dev/null +++ b/client/src/containers/projects/custom-project/cost-details/table/index.tsx @@ -0,0 +1,127 @@ +import { FC, useState } from "react"; + +import { ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons"; +import { + useReactTable, + getCoreRowModel, + flexRender, + SortingState, + getSortedRowModel, +} from "@tanstack/react-table"; +import { ChevronsUpDownIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +import { columns } from "@/containers/projects/custom-project/cost-details/table/columns"; +import mockData from "@/containers/projects/custom-project/mock-data"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +export interface CostItem { + name: string; + label: string; + value: number; +} + +const CostDetailTable: FC = () => { + const [sorting, setSorting] = useState([]); + const table = useReactTable({ + data: mockData.costDetails, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + state: { + sorting, + }, + onSortingChange: setSorting, + }); + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + {{ + asc: , + desc: , + }[header.column.getIsSorted() as string] ?? ( + + )} +
+ )} +
+ ); + })} +
+ ))} +
+ + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ ); +}; + +export default CostDetailTable; diff --git a/client/src/containers/projects/custom-project/cost/index.tsx b/client/src/containers/projects/custom-project/cost/index.tsx new file mode 100644 index 00000000..27323722 --- /dev/null +++ b/client/src/containers/projects/custom-project/cost/index.tsx @@ -0,0 +1,93 @@ +import { FC } from "react"; + +import { useSetAtom } from "jotai"; + +import { renderCurrency } from "@/lib/format"; + +import { showCostDetailsAtom } from "@/app/projects/[id]/store"; + +import mockData from "@/containers/projects/custom-project/mock-data"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Graph, GraphLegend } from "@/components/ui/graph"; +import { Label } from "@/components/ui/label"; + +const ProjectCost: FC = () => { + const setShowCostDetails = useSetAtom(showCostDetailsAtom); + + return ( + + +
+
+ +
+
+ Refers to the summary of Capital Expenditure and Operating + Expenditure +
+
+ + +
+ + +
+
+
+ + {renderCurrency(mockData.totalCost)} + +
+ +
+ +
+
+
+ ); +}; + +export default ProjectCost; diff --git a/client/src/containers/projects/custom-project/details/detail-item/index.tsx b/client/src/containers/projects/custom-project/details/detail-item/index.tsx new file mode 100644 index 00000000..bce374d1 --- /dev/null +++ b/client/src/containers/projects/custom-project/details/detail-item/index.tsx @@ -0,0 +1,75 @@ +import { FC } from "react"; + +import ReactCountryFlag from "react-country-flag"; + +import Metric from "@/components/ui/metric"; + +interface SubValue { + label: string; + value: string | number; + unit: string; +} + +interface DetailItemProps { + label: string; + value?: string | number; + unit?: string; + countryCode?: string; + subValues?: SubValue[]; + numberFormatOptions?: Intl.NumberFormatOptions; +} + +const formatValue = (value?: string | number) => { + if (!value) return null; + if (typeof value === "string") return value; + + return Math.round((value + Number.EPSILON) * 100) / 100; +}; + +const DetailItem: FC = ({ + label, + value, + unit, + countryCode, + subValues, + numberFormatOptions = {}, +}) => { + const isMetric = unit && typeof value === "number"; + + return ( +
+

{label}

+
+ {countryCode && ( + + )} + {isMetric ? ( + + ) : ( + {formatValue(value)} + )} +
+ {subValues?.map((subValue, index) => ( +

+ + {subValue.label} + + {formatValue(subValue.value)} + {subValue.unit} +

+ ))} +
+ ); +}; + +export default DetailItem; diff --git a/client/src/containers/projects/custom-project/details/index.tsx b/client/src/containers/projects/custom-project/details/index.tsx new file mode 100644 index 00000000..2bfbd5e6 --- /dev/null +++ b/client/src/containers/projects/custom-project/details/index.tsx @@ -0,0 +1,113 @@ +import { FC } from "react"; + +import { ACTIVITY } from "@shared/entities/activity.enum"; +import { CARBON_REVENUES_TO_COVER } from "@shared/entities/custom-project.entity"; +import { ECOSYSTEM } from "@shared/entities/ecosystem.enum"; + +import DetailItem from "@/containers/projects/custom-project/details/detail-item"; + +import FileEdit from "@/components/icons/file-edit"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; + +interface ProjectDetailsProps { + country: { code: string; name: string }; + projectSize: number; + projectLength: number; + ecosystem: ECOSYSTEM; + activity: ACTIVITY; + lossRate: number; + carbonRevenuesToCover: CARBON_REVENUES_TO_COVER; + initialCarbonPrice: number; + emissionFactors: { + emissionFactor: number; + emissionFactorAGB: number; + emissionFactorSOC: number; + }; +} + +const ProjectDetails: FC = ({ + country, + projectSize, + projectLength, + ecosystem, + carbonRevenuesToCover, + activity, + initialCarbonPrice, + lossRate, + emissionFactors, +}) => { + return ( + +
+

Project details

+ +
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ ); +}; + +export default ProjectDetails; diff --git a/client/src/containers/projects/custom-project/header/index.tsx b/client/src/containers/projects/custom-project/header/index.tsx new file mode 100644 index 00000000..c1f2e2d2 --- /dev/null +++ b/client/src/containers/projects/custom-project/header/index.tsx @@ -0,0 +1,62 @@ +import { FC } from "react"; + +import { useAtom } from "jotai"; +import { LayoutListIcon } from "lucide-react"; +import { useSession } from "next-auth/react"; + +import { cn } from "@/lib/utils"; + +import { projectsUIState } from "@/app/projects/[id]/store"; + +import AuthDialog from "@/containers/auth/dialog"; +import CustomProjectParameters from "@/containers/projects/custom-project/header/parameters"; +import Topbar from "@/containers/topbar"; + +import { Button } from "@/components/ui/button"; +import { useToast } from "@/components/ui/toast/use-toast"; + +const CustomProjectHeader: FC = () => { + const [{ projectSummaryOpen }, setProjectSummaryOpen] = + useAtom(projectsUIState); + const { data: session } = useSession(); + const { toast } = useToast(); + const handleSaveButtonClick = () => { + // TODO: Add API call when available + toast({ description: "Project updated successfully." }); + }; + + return ( + +
+ + + {session ? ( + + ) : ( + Save project} + onSignIn={handleSaveButtonClick} + /> + )} +
+
+ ); +}; + +export default CustomProjectHeader; diff --git a/client/src/containers/projects/custom-project/header/parameters/index.tsx b/client/src/containers/projects/custom-project/header/parameters/index.tsx new file mode 100644 index 00000000..91e25f9f --- /dev/null +++ b/client/src/containers/projects/custom-project/header/parameters/index.tsx @@ -0,0 +1,100 @@ +import { + COST_TYPE_SELECTOR, + PROJECT_PRICE_TYPE, +} from "@shared/entities/projects.entity"; +import { z } from "zod"; + +import { FILTER_KEYS } from "@/app/(overview)/constants"; + +import { INITIAL_COST_RANGE } from "@/containers/overview/filters/constants"; +import { + filtersSchema, + useCustomProjectFilters, +} from "@/containers/projects/url-store"; + +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +export const PROJECT_PARAMETERS = [ + { + key: FILTER_KEYS[3], + label: "Project size", + className: "w-[125px]", + options: [ + { + label: COST_TYPE_SELECTOR.NPV, + value: COST_TYPE_SELECTOR.NPV, + }, + { + label: COST_TYPE_SELECTOR.TOTAL, + value: COST_TYPE_SELECTOR.TOTAL, + }, + ], + }, + { + key: FILTER_KEYS[2], + label: "Carbon pricing type", + className: "w-[195px]", + options: [ + { + label: PROJECT_PRICE_TYPE.MARKET_PRICE, + value: PROJECT_PRICE_TYPE.MARKET_PRICE, + }, + { + label: PROJECT_PRICE_TYPE.OPEN_BREAK_EVEN_PRICE, + value: PROJECT_PRICE_TYPE.OPEN_BREAK_EVEN_PRICE, + }, + ], + }, +] as const; + +export default function CustomProjectParameters() { + const [filters, setFilters] = useCustomProjectFilters(); + + const handleParameters = async ( + v: string, + parameter: keyof Omit, "keyword">, + ) => { + await setFilters((prev) => ({ + ...prev, + [parameter]: v, + ...(parameter === "costRangeSelector" && { + costRange: INITIAL_COST_RANGE[v as COST_TYPE_SELECTOR], + }), + })); + }; + + return ( +
+ {PROJECT_PARAMETERS.map((parameter) => ( +
+ + +
+ ))} +
+ ); +} diff --git a/client/src/containers/projects/custom-project/index.tsx b/client/src/containers/projects/custom-project/index.tsx new file mode 100644 index 00000000..6bd9f827 --- /dev/null +++ b/client/src/containers/projects/custom-project/index.tsx @@ -0,0 +1,85 @@ +"use client"; +import { FC } from "react"; + +import { motion } from "framer-motion"; +import { useAtomValue } from "jotai"; + +import { LAYOUT_TRANSITIONS } from "@/app/(overview)/constants"; +import { projectsUIState } from "@/app/projects/[id]/store"; + +import AnnualProjectCashFlow from "@/containers/projects/custom-project/annual-project-cash-flow"; +import ProjectCost from "@/containers/projects/custom-project/cost"; +import CostDetails from "@/containers/projects/custom-project/cost-details"; +import ProjectDetails from "@/containers/projects/custom-project/details"; +import CustomProjectHeader from "@/containers/projects/custom-project/header"; +import LeftOver from "@/containers/projects/custom-project/left-over"; +import mockData from "@/containers/projects/custom-project/mock-data"; +import ProjectSummary from "@/containers/projects/custom-project/summary"; + +import { useSidebar } from "@/components/ui/sidebar"; + +const { + country, + projectSize, + projectLength, + ecosystem, + activity, + lossRate, + carbonRevenuesToCover, + initialCarbonPrice, + emissionFactors, +} = mockData; +export const SUMMARY_SIDEBAR_WIDTH = 400; +const CustomProject: FC = () => { + const { projectSummaryOpen } = useAtomValue(projectsUIState); + const { open: navOpen } = useSidebar(); + + return ( + + + + +
+ +
+ + + + +
+ +
+
+ ); +}; + +export default CustomProject; diff --git a/client/src/containers/projects/custom-project/left-over/index.tsx b/client/src/containers/projects/custom-project/left-over/index.tsx new file mode 100644 index 00000000..9385996e --- /dev/null +++ b/client/src/containers/projects/custom-project/left-over/index.tsx @@ -0,0 +1,75 @@ +import { FC } from "react"; + +import { renderCurrency } from "@/lib/format"; + +import mockData from "@/containers/projects/custom-project/mock-data"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Graph, GraphLegend } from "@/components/ui/graph"; +import { Label } from "@/components/ui/label"; + +const LeftOver: FC = () => { + return ( + + +
+
+ +
+
+ Refers to the difference between Total Revenue and Operating + Expenditure. +
+
+
+ + +
+
+
+ + {renderCurrency(mockData.leftover)} + +
+ +
+ +
+
+
+ ); +}; + +export default LeftOver; diff --git a/client/src/containers/projects/custom-project/mock-data.ts b/client/src/containers/projects/custom-project/mock-data.ts new file mode 100644 index 00000000..4031ac51 --- /dev/null +++ b/client/src/containers/projects/custom-project/mock-data.ts @@ -0,0 +1,216 @@ +import { + ACTIVITY, + RESTORATION_ACTIVITY_SUBTYPE, +} from "@shared/entities/activity.enum"; +import { CARBON_REVENUES_TO_COVER } from "@shared/entities/custom-project.entity"; +import { ECOSYSTEM } from "@shared/entities/ecosystem.enum"; + +// TODO: tooltip info will go to constants/tooltip-info.ts +const tooltip = { + title: "Info", + content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", +}; + +const mockData = { + country: { code: "ID", name: "Indonesia" }, + projectSize: 20, + projectLength: 20, + ecosystem: ECOSYSTEM.SEAGRASS, + activity: ACTIVITY.CONSERVATION, + subActivity: RESTORATION_ACTIVITY_SUBTYPE.HYBRID, + lossRate: -0.1, + carbonRevenuesToCover: CARBON_REVENUES_TO_COVER.OPEX, + initialCarbonPrice: 30, + totalCost: 38023789, + capEx: 1500000, + opEx: 36500000, + leftover: 4106132, + totalRevenue: 40600000, + opExRevenue: 36500000, + emissionFactors: { + emissionFactor: 355, + emissionFactorAGB: 355, + emissionFactorSOC: 72, + }, + details: [ + [ + { + label: "Country", + value: "Indonesia", + countryCode: "ID", + }, + { + label: "Ecosystem", + value: "Seagrass", + }, + { + label: "Carbon revenues to cover", + value: "Only Opex", + }, + ], + [ + { + label: "Project size", + value: 20, + unit: "hectares", + }, + { + label: "Activity type", + value: "Conservation", + }, + { + label: "Initial carbon price", + value: 30, + unit: "$", + }, + ], + [ + { + label: "Project length", + value: 20, + unit: "years", + }, + { + label: "Loss rate", + value: "-0.10", + unit: "%", + }, + { + label: "Emission factor", + subValues: [ + { + label: "AGB", + value: 355, + unit: "tCO2e/ha/yr", + }, + { + label: "SOC", + value: 72, + unit: "tCO2e/ha/yr", + }, + ], + }, + ], + ], + summary: [ + { + name: "$/tCO2e (total cost, NPV)", + value: 16, + unit: "$", + tooltip, + }, + { + name: "$/ha", + value: 358, + unit: "$", + tooltip, + }, + { + name: "Leftover after OpEx / total cost", + value: 392807, + unit: "$", + tooltip, + }, + { + name: "IRR when priced to cover opex", + value: 18.5, + unit: "%", + tooltip, + }, + { + name: "IRR when priced to cover total costs", + value: -1.1, + unit: "%", + tooltip, + }, + { + name: "Funding gap (NPV)", + unit: "%", + tooltip, + }, + ], + costDetails: [ + { + name: "capitalExpenditure", + value: 1514218, + label: "Capital expenditure", + }, + { + name: "feasibilityAnalysis", + value: 70000, + label: "Feasibility analysis", + }, + { + name: "conservationPlanningAndAdmin", + value: 629559, + label: "Conservation planning and admin", + }, + { + name: "dataCollectionAndFieldCosts", + value: 76963, + label: "Data collection and field costs", + }, + { + name: "communityRepresentation", + value: 286112, + label: "Community representation", + }, + { + name: "blueCarbonProjectPlanning", + value: 111125, + label: "Blue carbon project planning", + }, + { + name: "establishingCarbonRights", + value: 296010, + label: "Establishing carbon rights", + }, + { + name: "validation", + value: 44450, + label: "Validation", + }, + { + name: "implementationLabor", + value: 0, + label: "Implementation labor", + }, + { + name: "operatingExpenditure", + value: 36509571, + label: "Operating expenditure", + }, + { + name: "monitoringAndMaintenance", + value: 402322, + label: "Monitoring and Maintenance", + }, + { + name: "communityBenefitSharingFund", + value: 34523347, + label: "Community benefit sharing fund", + }, + { + name: "carbonStandardFees", + value: 227875, + label: "Carbon standard fees", + }, + { + name: "baselineReassessment", + value: 75812, + label: "Baseline reassessment", + }, + { + name: "mrv", + value: 223062, + label: "MRV", + }, + { + name: "totalCost", + value: 38023789, + label: "Total cost", + }, + ], +}; + +export default mockData; diff --git a/client/src/containers/projects/custom-project/summary/index.tsx b/client/src/containers/projects/custom-project/summary/index.tsx new file mode 100644 index 00000000..35727be3 --- /dev/null +++ b/client/src/containers/projects/custom-project/summary/index.tsx @@ -0,0 +1,70 @@ +import { FC } from "react"; + +import { useSetAtom } from "jotai"; +import { XIcon } from "lucide-react"; + +import { projectsUIState } from "@/app/projects/[id]/store"; + +import { SUMMARY_SIDEBAR_WIDTH } from "@/containers/projects/custom-project"; +import mockData from "@/containers/projects/custom-project/mock-data"; + +import FileEdit from "@/components/icons/file-edit"; +import { Button } from "@/components/ui/button"; +import InfoButton from "@/components/ui/info-button"; +import Metric from "@/components/ui/metric"; + +const ProjectSummary: FC = () => { + const setProjectSummaryOpen = useSetAtom(projectsUIState); + + return ( +
+ +
+

Summary

+
+
    + {mockData.summary.map(({ name, tooltip, unit, value }) => ( +
    +
    +
    {name}
    + {tooltip.content} +
    +
    + +
    +
    + ))} +
+
+

+ Calculations based on project setup parameters. For new calculations, + edit project details. +

+ +
+
+ ); +}; + +export default ProjectSummary; diff --git a/client/src/containers/projects/url-store.ts b/client/src/containers/projects/url-store.ts new file mode 100644 index 00000000..46337042 --- /dev/null +++ b/client/src/containers/projects/url-store.ts @@ -0,0 +1,25 @@ +import { + COST_TYPE_SELECTOR, + PROJECT_PRICE_TYPE, +} from "@shared/entities/projects.entity"; +import { parseAsJson, useQueryState } from "nuqs"; +import { z } from "zod"; + +import { FILTER_KEYS } from "@/app/(overview)/constants"; + +export const filtersSchema = z.object({ + [FILTER_KEYS[2]]: z.nativeEnum(PROJECT_PRICE_TYPE), + [FILTER_KEYS[3]]: z.nativeEnum(COST_TYPE_SELECTOR), +}); + +export const INITIAL_FILTERS_STATE: z.infer = { + priceType: PROJECT_PRICE_TYPE.OPEN_BREAK_EVEN_PRICE, + costRangeSelector: COST_TYPE_SELECTOR.NPV, +}; + +export function useCustomProjectFilters() { + return useQueryState( + "filters", + parseAsJson(filtersSchema.parse).withDefault(INITIAL_FILTERS_STATE), + ); +} diff --git a/client/src/containers/topbar/index.tsx b/client/src/containers/topbar/index.tsx new file mode 100644 index 00000000..b19f5f22 --- /dev/null +++ b/client/src/containers/topbar/index.tsx @@ -0,0 +1,26 @@ +import { FC, PropsWithChildren } from "react"; + +import { cn } from "@/lib/utils"; + +import { SidebarTrigger } from "@/components/ui/sidebar"; + +interface TopbarProps extends PropsWithChildren { + title: string; + className?: HTMLDivElement["className"]; +} + +const Topbar: FC = ({ title, className, children }) => { + return ( +
+
+ +

{title}

+
+ {children} +
+ ); +}; + +export default Topbar; diff --git a/client/src/public/forms/carbon-input-template.xlsx b/client/src/public/templates/carbon-input-template.xlsx similarity index 100% rename from client/src/public/forms/carbon-input-template.xlsx rename to client/src/public/templates/carbon-input-template.xlsx diff --git a/client/src/public/forms/cost-input-template.xlsx b/client/src/public/templates/cost-input-template.xlsx similarity index 100% rename from client/src/public/forms/cost-input-template.xlsx rename to client/src/public/templates/cost-input-template.xlsx diff --git a/data/excel/data_ingestion_project_scorecard.xlsm b/data/excel/data_ingestion_project_scorecard.xlsm new file mode 100644 index 00000000..0d9ec1bd Binary files /dev/null and b/data/excel/data_ingestion_project_scorecard.xlsm differ diff --git a/data/src/bcc_model/cost_calculator.py b/data/src/bcc_model/cost_calculator.py index 9d32839d..2533211b 100644 --- a/data/src/bcc_model/cost_calculator.py +++ b/data/src/bcc_model/cost_calculator.py @@ -34,14 +34,15 @@ def __init__(self, project): } # Calculate Capital expenditure (NPV) - self.capex_cost_plan = self.calculate_capex_total() - self.total_capex = sum(self.capex_cost_plan.values()) - self.total_capex_NPV = calculate_npv(self.capex_cost_plan, self.project.discount_rate) + self.capex_cost_plan = self.calculate_capex_total() # done + self.opex_cost_plan = self.calculate_opex_total() # done + self.total_capex = sum(self.capex_cost_plan.values()) # done + self.total_capex_NPV = calculate_npv(self.capex_cost_plan, self.project.discount_rate) # done # Operating expenditure (NPV) - self.opex_cost_plan = self.calculate_opex_total() - self.total_opex = sum(self.opex_cost_plan.values()) - self.total_opex_NPV = calculate_npv(self.opex_cost_plan, self.project.discount_rate) - self.total_NPV = self.total_capex_NPV + self.total_opex_NPV + + self.total_opex = sum(self.opex_cost_plan.values()) # done + self.total_opex_NPV = calculate_npv(self.opex_cost_plan, self.project.discount_rate) # done + self.total_NPV = self.total_capex_NPV + self.total_opex_NPV # done # Calculate estimated revenue (NPV) self.estimated_revenue_plan = self.revenue_profit_calculator.calculate_est_revenue() # Total revenue (non-discounted) diff --git a/docker-compose.yml b/docker-compose.yml index d9983616..b528621e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,8 @@ services: - "1000:1000" networks: - 4-growth-docker-network - + depends_on: + - database client: build: @@ -42,6 +43,22 @@ services: networks: - 4-growth-docker-network + # Used to test the integration between the client, api and backoffice. + # Some dependencies are disabled as we typically use nginx and database containers only. + nginx: + image: nginx + volumes: + - ./nginx/conf.d:/etc/nginx/conf.d + ports: + - 80:80 + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + - database + # - client + # - api + # - admin + database: image: postgis/postgis:16-3.4 container_name: blue-carbon-cost-db diff --git a/e2e/tests/auth/sign-up.spec.ts b/e2e/tests/auth/sign-up.spec.ts index 754cf5e7..1a08ff73 100644 --- a/e2e/tests/auth/sign-up.spec.ts +++ b/e2e/tests/auth/sign-up.spec.ts @@ -36,7 +36,7 @@ test.describe("Auth - Sign Up", () => { await page.goto(`/auth/signup`); await page.getByPlaceholder("Enter your name").fill(user.name); - await page.getByPlaceholder("Enter partner name").fill(user.partnerName); + await page.getByPlaceholder("Enter organization name").fill(user.partnerName); await page.getByLabel("Email").fill(user.email); await page.getByRole("checkbox").check(); diff --git a/infrastructure/modules/env/api_env_vars.tf b/infrastructure/modules/env/api_env_vars.tf index 5ee05bd8..1377109b 100644 --- a/infrastructure/modules/env/api_env_vars.tf +++ b/infrastructure/modules/env/api_env_vars.tf @@ -14,6 +14,11 @@ resource "random_password" "email_confirmation_token_secret" { special = true override_special = "!#%&*()-_=+[]{}<>:?" } +resource "random_password" "backoffice_session_cookie_secret" { + length = 32 + special = true + override_special = "!#%&*()-_=+[]{}<>:?" +} resource "aws_iam_access_key" "email_user_access_key" { user = module.email.iam_user.name @@ -37,8 +42,10 @@ locals { AWS_SES_ACCESS_KEY_ID = aws_iam_access_key.email_user_access_key.id AWS_SES_ACCESS_KEY_SECRET = aws_iam_access_key.email_user_access_key.secret AWS_SES_DOMAIN = module.email.mail_from_domain + BACKOFFICE_SESSION_COOKIE_SECRET = random_password.backoffice_session_cookie_secret.result + } api_env_vars = { - + BACKOFFICE_SESSION_COOKIE_NAME = "backoffice" } -} \ No newline at end of file +} diff --git a/infrastructure/source_bundle/proxy/conf.d/application.conf b/infrastructure/source_bundle/proxy/conf.d/application.conf index ead2f5c8..8c425653 100644 --- a/infrastructure/source_bundle/proxy/conf.d/application.conf +++ b/infrastructure/source_bundle/proxy/conf.d/application.conf @@ -6,8 +6,8 @@ upstream client { server client:3000; } -upstream admin { - server admin:1000; +upstream backoffice { + server backoffice:1000; } server { @@ -42,7 +42,7 @@ server { client_max_body_size 200m; } location /admin/ { - proxy_pass http://admin; + proxy_pass http://backoffice; proxy_http_version 1.1; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Server $host; diff --git a/nginx/conf.d/application.conf b/nginx/conf.d/application.conf new file mode 100644 index 00000000..46803bef --- /dev/null +++ b/nginx/conf.d/application.conf @@ -0,0 +1,57 @@ +upstream api { + server host.docker.internal:4000; +} + +upstream client { + server host.docker.internal:3000; +} + +upstream admin { + server host.docker.internal:1000; +} + +server { + listen 80; + + location / { + proxy_pass http://client; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/ { + rewrite ^/api/?(.*)$ /$1 break; + proxy_pass http://api; + proxy_http_version 1.1; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Server $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_pass_request_headers on; + client_max_body_size 200m; + } + + location /admin/ { + proxy_pass http://admin; + proxy_http_version 1.1; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Server $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_pass_request_headers on; + client_max_body_size 200m; + } +} + + + diff --git a/package.json b/package.json index e3fb6bb7..9406605c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "api:dev": "pnpm --filter api run start:dev", "api:build": "pnpm --filter api run build", "api:prod": "NODE_ENV=production pnpm --filter api run start:prod", - "admin:prod": "NODE_ENV=production pnpm --filter admin run start:prod", + "backoffice:prod": "NODE_ENV=production pnpm --filter backoffice run start:prod", "client:deps": "pnpm --filter client install", "client:dev": "pnpm --filter client run dev", "client:build": "pnpm --filter client run build", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a77c2261..ef1aed40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,58 +44,6 @@ importers: .: {} - admin: - dependencies: - '@adminjs/express': - specifier: ^6.1.0 - version: 6.1.0(adminjs@7.8.13(@tiptap/extension-text-style@2.8.0(@tiptap/core@2.1.13(@tiptap/pm@2.1.13)))(@types/babel__core@7.20.5)(@types/react-dom@18.3.0)(@types/react@18.3.5))(express-formidable@1.2.0)(express-session@1.18.0)(express@4.21.0)(tslib@2.7.0) - '@adminjs/typeorm': - specifier: ^5.0.1 - version: 5.0.1(adminjs@7.8.13(@tiptap/extension-text-style@2.8.0(@tiptap/core@2.1.13(@tiptap/pm@2.1.13)))(@types/babel__core@7.20.5)(@types/react-dom@18.3.0)(@types/react@18.3.5))(typeorm@0.3.20(pg@8.12.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.4.5))) - adminjs: - specifier: ^7.8.13 - version: 7.8.13(@tiptap/extension-text-style@2.8.0(@tiptap/core@2.1.13(@tiptap/pm@2.1.13)))(@types/babel__core@7.20.5)(@types/react-dom@18.3.0)(@types/react@18.3.5) - express: - specifier: ^4.21.0 - version: 4.21.0 - express-formidable: - specifier: ^1.2.0 - version: 1.2.0 - express-session: - specifier: ^1.18.0 - version: 1.18.0 - nodemon: - specifier: ^3.1.7 - version: 3.1.7 - pg: - specifier: 'catalog:' - version: 8.12.0 - reflect-metadata: - specifier: 'catalog:' - version: 0.2.2 - tslib: - specifier: ^2.7.0 - version: 2.7.0 - typeorm: - specifier: 'catalog:' - version: 0.3.20(pg@8.12.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.4.5)) - typescript: - specifier: 'catalog:' - version: 5.4.5 - devDependencies: - '@types/express': - specifier: ^4.17.17 - version: 4.17.21 - '@types/node': - specifier: ^22.7.4 - version: 22.7.5 - ts-node: - specifier: ^10.9.1 - version: 10.9.2(@types/node@22.7.5)(typescript@5.4.5) - tsx: - specifier: ^4.19.1 - version: 4.19.1 - api: dependencies: '@aws-sdk/client-ses': @@ -143,9 +91,9 @@ importers: dotenv: specifier: 16.4.5 version: 16.4.5 - financejs: - specifier: ^4.1.0 - version: 4.1.0 + financial: + specifier: ^0.2.4 + version: 0.2.4 jsonapi-serializer: specifier: ^3.6.9 version: 3.6.9 @@ -179,6 +127,9 @@ importers: typeorm: specifier: 'catalog:' version: 0.3.20(pg@8.12.0)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)) + uid-safe: + specifier: ^2.1.5 + version: 2.1.5 xlsx: specifier: ^0.18.5 version: 0.18.5 @@ -228,6 +179,9 @@ importers: '@types/supertest': specifier: ^6.0.0 version: 6.0.2 + '@types/uid-safe': + specifier: ^2.1.5 + version: 2.1.5 '@typescript-eslint/eslint-plugin': specifier: ^7.0.0 version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5) @@ -274,6 +228,85 @@ importers: specifier: 'catalog:' version: 5.4.5 + backoffice: + dependencies: + '@adminjs/design-system': + specifier: ^4.1.1 + version: 4.1.1(@babel/core@7.25.2)(@tiptap/extension-text-style@2.8.0(@tiptap/core@2.1.13(@tiptap/pm@2.1.13)))(@types/react@18.3.5)(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1) + '@adminjs/express': + specifier: ^6.1.0 + version: 6.1.0(adminjs@7.8.13(@tiptap/extension-text-style@2.8.0(@tiptap/core@2.1.13(@tiptap/pm@2.1.13)))(@types/babel__core@7.20.5)(@types/react-dom@18.3.0)(@types/react@18.3.5))(express-formidable@1.2.0)(express-session@1.18.0)(express@4.21.0)(tslib@2.7.0) + '@adminjs/typeorm': + specifier: ^5.0.1 + version: 5.0.1(adminjs@7.8.13(@tiptap/extension-text-style@2.8.0(@tiptap/core@2.1.13(@tiptap/pm@2.1.13)))(@types/babel__core@7.20.5)(@types/react-dom@18.3.0)(@types/react@18.3.5))(typeorm@0.3.20(pg@8.12.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.4.5))) + adminjs: + specifier: ^7.8.13 + version: 7.8.13(@tiptap/extension-text-style@2.8.0(@tiptap/core@2.1.13(@tiptap/pm@2.1.13)))(@types/babel__core@7.20.5)(@types/react-dom@18.3.0)(@types/react@18.3.5) + connect-pg-simple: + specifier: ^10.0.0 + version: 10.0.0 + cookie: + specifier: ^1.0.2 + version: 1.0.2 + cookie-parser: + specifier: ^1.4.7 + version: 1.4.7 + express: + specifier: ^4.21.0 + version: 4.21.0 + express-formidable: + specifier: ^1.2.0 + version: 1.2.0 + express-session: + specifier: ^1.18.0 + version: 1.18.0 + nodemon: + specifier: ^3.1.7 + version: 3.1.7 + pg: + specifier: 'catalog:' + version: 8.12.0 + reflect-metadata: + specifier: 'catalog:' + version: 0.2.2 + tslib: + specifier: ^2.7.0 + version: 2.7.0 + typeorm: + specifier: 'catalog:' + version: 0.3.20(pg@8.12.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.4.5)) + typescript: + specifier: 'catalog:' + version: 5.4.5 + devDependencies: + '@types/connect-pg-simple': + specifier: ^7.0.3 + version: 7.0.3 + '@types/cookie-parser': + specifier: ^1.4.8 + version: 1.4.8(@types/express@4.17.21) + '@types/express': + specifier: ^4.17.17 + version: 4.17.21 + '@types/express-session': + specifier: ^1.18.1 + version: 1.18.1 + '@types/node': + specifier: ^22.7.4 + version: 22.7.5 + '@types/pg': + specifier: ^8.11.10 + version: 8.11.10 + dotenv: + specifier: 16.4.5 + version: 16.4.5 + ts-node: + specifier: ^10.9.1 + version: 10.9.2(@types/node@22.7.5)(typescript@5.4.5) + tsx: + specifier: ^4.19.1 + version: 4.19.1 + client: dependencies: '@hookform/resolvers': @@ -378,6 +411,9 @@ importers: react: specifier: ^18 version: 18.3.1 + react-country-flag: + specifier: ^3.1.0 + version: 3.1.0(react@18.3.1) react-dom: specifier: ^18 version: 18.3.1(react@18.3.1) @@ -706,6 +742,10 @@ packages: resolution: {integrity: sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.26.2': + resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.25.4': resolution: {integrity: sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==} engines: {node: '>=6.9.0'} @@ -726,6 +766,10 @@ packages: resolution: {integrity: sha512-5Dqpl5fyV9pIAD62yK9P7fcA768uVPUyrQmqpqstHWgMma4feF1x/oFysBCVZLY5wJ2GkMUCdsNDnGZrPoR6rA==} engines: {node: '>=6.9.0'} + '@babel/generator@7.26.2': + resolution: {integrity: sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.25.7': resolution: {integrity: sha512-4xwU8StnqnlIhhioZf1tqnVWeQ9pvH/ujS8hRfw/WOza+/a+1qv69BWNy+oY231maTCWgKWhfBU7kDpsds6zAA==} engines: {node: '>=6.9.0'} @@ -767,16 +811,10 @@ packages: resolution: {integrity: sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.25.7': - resolution: {integrity: sha512-o0xCgpNmRohmnoWKQ0Ij8IdddjyBFE4T2kagL/x6M3+4zUgc+4qTOUBoNe4XxDskt1HPKO007ZPiMgLDq2s7Kw==} + '@babel/helper-module-imports@7.25.9': + resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.25.2': - resolution: {integrity: sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-module-transforms@7.25.7': resolution: {integrity: sha512-k/6f8dKG3yDz/qCwSM+RKovjMix563SLxQFo0UhRNo239SP6n9u5/eLtKD6EAjwta2JHJ49CsD8pms2HdNiMMQ==} engines: {node: '>=6.9.0'} @@ -807,10 +845,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-simple-access@7.24.7': - resolution: {integrity: sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==} - engines: {node: '>=6.9.0'} - '@babel/helper-simple-access@7.25.7': resolution: {integrity: sha512-FPGAkJmyoChQeM+ruBGIDyrT2tKfZJO8NcxdC+CWNJi7N8/rZpSxK7yvBJ5O/nF1gfu5KzN7VKG3YVSLFfRSxQ==} engines: {node: '>=6.9.0'} @@ -827,6 +861,10 @@ packages: resolution: {integrity: sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.25.9': + resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.24.7': resolution: {integrity: sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==} engines: {node: '>=6.9.0'} @@ -835,6 +873,10 @@ packages: resolution: {integrity: sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.25.9': + resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.24.8': resolution: {integrity: sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==} engines: {node: '>=6.9.0'} @@ -869,6 +911,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.26.2': + resolution: {integrity: sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.7': resolution: {integrity: sha512-UV9Lg53zyebzD1DwQoT9mzkEKa922LNUp5YkTJ6Uta0RbyXaQNUgcvSt7qIu1PpPzVb6rd10OVNTzkyBGeVmxQ==} engines: {node: '>=6.9.0'} @@ -1407,6 +1454,10 @@ packages: resolution: {integrity: sha512-wRwtAgI3bAS+JGU2upWNL9lSlDcRCqD05BZ1n3X2ONLH1WilFP6O1otQjeMK/1g0pvYcXC7b/qVUB1keofjtZA==} engines: {node: '>=6.9.0'} + '@babel/template@7.25.9': + resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.25.6': resolution: {integrity: sha512-9Vrcx5ZW6UwK5tvqsj0nGpp/XzqthkT0dqIc9g1AdtygFToNtTF67XzYS//dm+SAK9cp3B9R4ZO/46p63SCjlQ==} engines: {node: '>=6.9.0'} @@ -1415,6 +1466,10 @@ packages: resolution: {integrity: sha512-jatJPT1Zjqvh/1FyJs6qAHL+Dzb7sTb+xr7Q+gM1b+1oBsMsQQ4FkVKb6dFlJvLlVssqkRzV05Jzervt9yhnzg==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.25.9': + resolution: {integrity: sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.25.6': resolution: {integrity: sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==} engines: {node: '>=6.9.0'} @@ -1423,6 +1478,10 @@ packages: resolution: {integrity: sha512-vwIVdXG+j+FOpkwqHRcBgHLYNL7XMkufrlaFvL9o6Ai9sJn9+PdyIL5qa0XzTZw084c+u9LOls53eoZWP/W5WQ==} engines: {node: '>=6.9.0'} + '@babel/types@7.26.0': + resolution: {integrity: sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==} + engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -3201,9 +3260,17 @@ packages: '@types/body-parser@1.19.5': resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} + '@types/connect-pg-simple@7.0.3': + resolution: {integrity: sha512-NGCy9WBlW2bw+J/QlLnFZ9WjoGs6tMo3LAut6mY4kK+XHzue//lpNVpAvYRpIwM969vBRAM2Re0izUvV6kt+NA==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookie-parser@1.4.8': + resolution: {integrity: sha512-l37JqFrOJ9yQfRQkljb41l0xVphc7kg5JTjjr+pLRZ0IyZ49V4BQ8vbF4Ut2C2e+WH4al3xD3ZwYwIUfnbT4NQ==} + peerDependencies: + '@types/express': '*' + '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} @@ -3309,6 +3376,9 @@ packages: '@types/express-serve-static-core@4.19.5': resolution: {integrity: sha512-y6W03tvrACO72aijJ5uF02FRq5cgDR9lUxddQ8vyF+GvmjJQqbzDcJngEjURc+ZsG31VI3hODNZJ2URj86pzmg==} + '@types/express-session@1.18.1': + resolution: {integrity: sha512-S6TkD/lljxDlQ2u/4A70luD8/ZxZcrU5pQwI1rVXCiaVIywoFgbA+PIUNDjPhQpPdK0dGleLtYc/y7XWBfclBg==} + '@types/express@4.17.21': resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} @@ -3420,6 +3490,9 @@ packages: '@types/pbf@3.0.5': resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==} + '@types/pg@8.11.10': + resolution: {integrity: sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==} + '@types/prop-types@15.7.12': resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} @@ -3459,6 +3532,9 @@ packages: '@types/supertest@6.0.2': resolution: {integrity: sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==} + '@types/uid-safe@2.1.5': + resolution: {integrity: sha512-RwEfbxqXKEay2b5p8QQVllfnMbVPUZChiKKZ2M6+OSRRmvr4HTCCUZTWhr/QlmrMnNE0ViNBBbP1+5plF9OGRw==} + '@types/use-sync-external-store@0.0.3': resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==} @@ -4147,6 +4223,10 @@ packages: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} engines: {'0': node >= 0.8} + connect-pg-simple@10.0.0: + resolution: {integrity: sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=22.0.0} + consola@2.15.3: resolution: {integrity: sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==} @@ -4167,6 +4247,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-parser@1.4.7: + resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} + engines: {node: '>= 0.8.0'} + cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} @@ -4181,6 +4265,14 @@ packages: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} @@ -4892,8 +4984,9 @@ packages: resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} engines: {node: '>= 0.8'} - financejs@4.1.0: - resolution: {integrity: sha512-IE/SpTfCRsdl4TZWCGLo6/NNeg0q0QjU9eOoIxy7BWPCAH2truL3uqK+Kwu3f3kLd1trJK5vRKO+KGRNwNIzfg==} + financial@0.2.4: + resolution: {integrity: sha512-FNmbPW7o8oARCEJVOqb311oZp639fsnCkNltrXXahuqei7O8rm5QLTHEDbreRrrZAAmXjTGx5I8T0yPI3yyd9A==} + engines: {node: '>=18'} find-cache-dir@2.1.0: resolution: {integrity: sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==} @@ -6201,6 +6294,9 @@ packages: resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} engines: {node: '>= 0.4'} + obuf@1.1.2: + resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + oidc-token-hash@5.0.3: resolution: {integrity: sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==} engines: {node: ^10.13.0 || >=12.0.0} @@ -6361,6 +6457,10 @@ packages: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} + pg-numeric@1.0.2: + resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} + engines: {node: '>=4'} + pg-pool@3.6.2: resolution: {integrity: sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==} peerDependencies: @@ -6373,6 +6473,10 @@ packages: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} + pg-types@4.0.2: + resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==} + engines: {node: '>=10'} + pg@8.12.0: resolution: {integrity: sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==} engines: {node: '>= 8.0.0'} @@ -6491,18 +6595,37 @@ packages: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} + postgres-array@3.0.2: + resolution: {integrity: sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==} + engines: {node: '>=12'} + postgres-bytea@1.0.0: resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} engines: {node: '>=0.10.0'} + postgres-bytea@3.0.0: + resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==} + engines: {node: '>= 6'} + postgres-date@1.0.7: resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} engines: {node: '>=0.10.0'} + postgres-date@2.1.0: + resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==} + engines: {node: '>=12'} + postgres-interval@1.2.0: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + postgres-interval@3.0.0: + resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==} + engines: {node: '>=12'} + + postgres-range@1.1.4: + resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} + potpack@2.0.0: resolution: {integrity: sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==} @@ -6724,6 +6847,12 @@ packages: resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} engines: {node: '>= 0.8'} + react-country-flag@3.1.0: + resolution: {integrity: sha512-JWQFw1efdv9sTC+TGQvTKXQg1NKbDU2mBiAiRWcKM9F1sK+/zjhP2yGmm8YDddWyZdXVkR8Md47rPMJmo4YO5g==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16' + react-currency-input-field@3.8.0: resolution: {integrity: sha512-DKSIjacrvgUDOpuB16b+OVDvp5pbCt+s+RHJgpRZCHNhzg1yBpRUoy4fbnXpeOj0kdbwf5BaXCr2mAtxEujfhg==} peerDependencies: @@ -8476,6 +8605,12 @@ snapshots: '@babel/highlight': 7.25.7 picocolors: 1.1.0 + '@babel/code-frame@7.26.2': + dependencies: + '@babel/helper-validator-identifier': 7.25.9 + js-tokens: 4.0.0 + picocolors: 1.1.0 + '@babel/compat-data@7.25.4': {} '@babel/compat-data@7.25.7': {} @@ -8483,14 +8618,14 @@ snapshots: '@babel/core@7.25.2': dependencies: '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.24.7 - '@babel/generator': 7.25.6 - '@babel/helper-compilation-targets': 7.25.2 - '@babel/helper-module-transforms': 7.25.2(@babel/core@7.25.2) + '@babel/code-frame': 7.25.7 + '@babel/generator': 7.25.7 + '@babel/helper-compilation-targets': 7.25.7 + '@babel/helper-module-transforms': 7.25.7(@babel/core@7.25.2) '@babel/helpers': 7.25.6 '@babel/parser': 7.25.6 '@babel/template': 7.25.0 - '@babel/traverse': 7.25.6(supports-color@5.5.0) + '@babel/traverse': 7.25.6 '@babel/types': 7.25.6 convert-source-map: 2.0.0 debug: 4.3.6(supports-color@5.5.0) @@ -8502,7 +8637,7 @@ snapshots: '@babel/generator@7.25.6': dependencies: - '@babel/types': 7.25.6 + '@babel/types': 7.25.7 '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 jsesc: 2.5.2 @@ -8514,13 +8649,21 @@ snapshots: '@jridgewell/trace-mapping': 0.3.25 jsesc: 3.0.2 + '@babel/generator@7.26.2': + dependencies: + '@babel/parser': 7.26.2 + '@babel/types': 7.26.0 + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 3.0.2 + '@babel/helper-annotate-as-pure@7.25.7': dependencies: '@babel/types': 7.25.7 '@babel/helper-builder-binary-assignment-operator-visitor@7.25.7': dependencies: - '@babel/traverse': 7.25.7 + '@babel/traverse': 7.25.7(supports-color@5.5.0) '@babel/types': 7.25.7 transitivePeerDependencies: - supports-color @@ -8549,7 +8692,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.25.7 '@babel/helper-replace-supers': 7.25.7(@babel/core@7.25.2) '@babel/helper-skip-transparent-expression-wrappers': 7.25.7 - '@babel/traverse': 7.25.7 + '@babel/traverse': 7.25.7(supports-color@5.5.0) semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -8574,42 +8717,32 @@ snapshots: '@babel/helper-member-expression-to-functions@7.25.7': dependencies: - '@babel/traverse': 7.25.7 + '@babel/traverse': 7.25.7(supports-color@5.5.0) '@babel/types': 7.25.7 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.24.7(supports-color@5.5.0)': dependencies: - '@babel/traverse': 7.25.6(supports-color@5.5.0) - '@babel/types': 7.25.6 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.25.7': - dependencies: - '@babel/traverse': 7.25.7 + '@babel/traverse': 7.25.7(supports-color@5.5.0) '@babel/types': 7.25.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.25.2(@babel/core@7.25.2)': + '@babel/helper-module-imports@7.25.9': dependencies: - '@babel/core': 7.25.2 - '@babel/helper-module-imports': 7.24.7(supports-color@5.5.0) - '@babel/helper-simple-access': 7.24.7 - '@babel/helper-validator-identifier': 7.24.7 - '@babel/traverse': 7.25.6(supports-color@5.5.0) + '@babel/traverse': 7.25.9 + '@babel/types': 7.26.0 transitivePeerDependencies: - supports-color '@babel/helper-module-transforms@7.25.7(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 - '@babel/helper-module-imports': 7.25.7 + '@babel/helper-module-imports': 7.25.9 '@babel/helper-simple-access': 7.25.7 '@babel/helper-validator-identifier': 7.25.7 - '@babel/traverse': 7.25.7 + '@babel/traverse': 7.25.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -8626,7 +8759,7 @@ snapshots: '@babel/core': 7.25.2 '@babel/helper-annotate-as-pure': 7.25.7 '@babel/helper-wrap-function': 7.25.7 - '@babel/traverse': 7.25.7 + '@babel/traverse': 7.25.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -8635,27 +8768,20 @@ snapshots: '@babel/core': 7.25.2 '@babel/helper-member-expression-to-functions': 7.25.7 '@babel/helper-optimise-call-expression': 7.25.7 - '@babel/traverse': 7.25.7 - transitivePeerDependencies: - - supports-color - - '@babel/helper-simple-access@7.24.7': - dependencies: - '@babel/traverse': 7.25.6(supports-color@5.5.0) - '@babel/types': 7.25.6 + '@babel/traverse': 7.25.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color '@babel/helper-simple-access@7.25.7': dependencies: - '@babel/traverse': 7.25.7 + '@babel/traverse': 7.25.7(supports-color@5.5.0) '@babel/types': 7.25.7 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.25.7': dependencies: - '@babel/traverse': 7.25.7 + '@babel/traverse': 7.25.7(supports-color@5.5.0) '@babel/types': 7.25.7 transitivePeerDependencies: - supports-color @@ -8664,10 +8790,14 @@ snapshots: '@babel/helper-string-parser@7.25.7': {} + '@babel/helper-string-parser@7.25.9': {} + '@babel/helper-validator-identifier@7.24.7': {} '@babel/helper-validator-identifier@7.25.7': {} + '@babel/helper-validator-identifier@7.25.9': {} + '@babel/helper-validator-option@7.24.8': {} '@babel/helper-validator-option@7.25.7': {} @@ -8675,15 +8805,15 @@ snapshots: '@babel/helper-wrap-function@7.25.7': dependencies: '@babel/template': 7.25.7 - '@babel/traverse': 7.25.7 + '@babel/traverse': 7.25.7(supports-color@5.5.0) '@babel/types': 7.25.7 transitivePeerDependencies: - supports-color '@babel/helpers@7.25.6': dependencies: - '@babel/template': 7.25.0 - '@babel/types': 7.25.6 + '@babel/template': 7.25.7 + '@babel/types': 7.25.7 '@babel/highlight@7.24.7': dependencies: @@ -8707,11 +8837,15 @@ snapshots: dependencies: '@babel/types': 7.25.7 + '@babel/parser@7.26.2': + dependencies: + '@babel/types': 7.26.0 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.7(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 '@babel/helper-plugin-utils': 7.25.7 - '@babel/traverse': 7.25.7 + '@babel/traverse': 7.25.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -8738,7 +8872,7 @@ snapshots: dependencies: '@babel/core': 7.25.2 '@babel/helper-plugin-utils': 7.25.7 - '@babel/traverse': 7.25.7 + '@babel/traverse': 7.25.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -8878,14 +9012,14 @@ snapshots: '@babel/helper-plugin-utils': 7.25.7 '@babel/helper-remap-async-to-generator': 7.25.7(@babel/core@7.25.2) '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.25.2) - '@babel/traverse': 7.25.7 + '@babel/traverse': 7.25.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color '@babel/plugin-transform-async-to-generator@7.25.7(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 - '@babel/helper-module-imports': 7.25.7 + '@babel/helper-module-imports': 7.25.9 '@babel/helper-plugin-utils': 7.25.7 '@babel/helper-remap-async-to-generator': 7.25.7(@babel/core@7.25.2) transitivePeerDependencies: @@ -8925,7 +9059,7 @@ snapshots: '@babel/helper-compilation-targets': 7.25.7 '@babel/helper-plugin-utils': 7.25.7 '@babel/helper-replace-supers': 7.25.7(@babel/core@7.25.2) - '@babel/traverse': 7.25.7 + '@babel/traverse': 7.25.7(supports-color@5.5.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -8991,7 +9125,7 @@ snapshots: '@babel/core': 7.25.2 '@babel/helper-compilation-targets': 7.25.7 '@babel/helper-plugin-utils': 7.25.7 - '@babel/traverse': 7.25.7 + '@babel/traverse': 7.25.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -9040,7 +9174,7 @@ snapshots: '@babel/helper-module-transforms': 7.25.7(@babel/core@7.25.2) '@babel/helper-plugin-utils': 7.25.7 '@babel/helper-validator-identifier': 7.25.7 - '@babel/traverse': 7.25.7 + '@babel/traverse': 7.25.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -9150,7 +9284,7 @@ snapshots: dependencies: '@babel/core': 7.25.2 '@babel/helper-annotate-as-pure': 7.25.7 - '@babel/helper-module-imports': 7.25.7 + '@babel/helper-module-imports': 7.25.9 '@babel/helper-plugin-utils': 7.25.7 '@babel/plugin-syntax-jsx': 7.25.7(@babel/core@7.25.2) '@babel/types': 7.25.7 @@ -9177,7 +9311,7 @@ snapshots: '@babel/plugin-transform-runtime@7.25.7(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 - '@babel/helper-module-imports': 7.25.7 + '@babel/helper-module-imports': 7.25.9 '@babel/helper-plugin-utils': 7.25.7 babel-plugin-polyfill-corejs2: 0.4.11(@babel/core@7.25.2) babel-plugin-polyfill-corejs3: 0.10.6(@babel/core@7.25.2) @@ -9382,9 +9516,9 @@ snapshots: '@babel/template@7.25.0': dependencies: - '@babel/code-frame': 7.24.7 - '@babel/parser': 7.25.6 - '@babel/types': 7.25.6 + '@babel/code-frame': 7.25.7 + '@babel/parser': 7.25.7 + '@babel/types': 7.25.7 '@babel/template@7.25.7': dependencies: @@ -9392,7 +9526,13 @@ snapshots: '@babel/parser': 7.25.7 '@babel/types': 7.25.7 - '@babel/traverse@7.25.6(supports-color@5.5.0)': + '@babel/template@7.25.9': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/parser': 7.26.2 + '@babel/types': 7.26.0 + + '@babel/traverse@7.25.6': dependencies: '@babel/code-frame': 7.24.7 '@babel/generator': 7.25.6 @@ -9404,7 +9544,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/traverse@7.25.7': + '@babel/traverse@7.25.7(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.25.7 '@babel/generator': 7.25.7 @@ -9416,6 +9556,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.25.9': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.26.2 + '@babel/parser': 7.26.2 + '@babel/template': 7.25.9 + '@babel/types': 7.26.0 + debug: 4.3.6(supports-color@5.5.0) + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + '@babel/types@7.25.6': dependencies: '@babel/helper-string-parser': 7.24.8 @@ -9428,6 +9580,11 @@ snapshots: '@babel/helper-validator-identifier': 7.25.7 to-fast-properties: 2.0.0 + '@babel/types@7.26.0': + dependencies: + '@babel/helper-string-parser': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + '@bcoe/v8-coverage@0.2.3': {} '@colors/colors@1.5.0': @@ -11352,24 +11509,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.25.6 - '@babel/types': 7.25.6 + '@babel/parser': 7.25.7 + '@babel/types': 7.25.7 '@types/babel__generator': 7.6.8 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.20.6 '@types/babel__generator@7.6.8': dependencies: - '@babel/types': 7.25.6 + '@babel/types': 7.25.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.25.6 - '@babel/types': 7.25.6 + '@babel/parser': 7.25.7 + '@babel/types': 7.25.7 '@types/babel__traverse@7.20.6': dependencies: - '@babel/types': 7.25.6 + '@babel/types': 7.25.7 '@types/bcrypt@5.0.2': dependencies: @@ -11380,10 +11537,20 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 22.7.5 + '@types/connect-pg-simple@7.0.3': + dependencies: + '@types/express': 4.17.21 + '@types/express-session': 1.18.1 + '@types/pg': 8.11.10 + '@types/connect@3.4.38': dependencies: '@types/node': 22.7.5 + '@types/cookie-parser@1.4.8(@types/express@4.17.21)': + dependencies: + '@types/express': 4.17.21 + '@types/cookiejar@2.1.5': {} '@types/d3-array@3.2.1': {} @@ -11514,6 +11681,10 @@ snapshots: '@types/range-parser': 1.2.7 '@types/send': 0.17.4 + '@types/express-session@1.18.1': + dependencies: + '@types/express': 4.17.21 + '@types/express@4.17.21': dependencies: '@types/body-parser': 1.19.5 @@ -11642,6 +11813,12 @@ snapshots: '@types/pbf@3.0.5': {} + '@types/pg@8.11.10': + dependencies: + '@types/node': 22.7.5 + pg-protocol: 1.6.1 + pg-types: 4.0.2 + '@types/prop-types@15.7.12': {} '@types/qs@6.9.15': {} @@ -11692,6 +11869,8 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/uid-safe@2.1.5': {} + '@types/use-sync-external-store@0.0.3': {} '@types/uuid@9.0.8': {} @@ -12157,8 +12336,8 @@ snapshots: babel-plugin-jest-hoist@29.6.3: dependencies: - '@babel/template': 7.25.0 - '@babel/types': 7.25.6 + '@babel/template': 7.25.7 + '@babel/types': 7.25.7 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.20.6 @@ -12196,7 +12375,7 @@ snapshots: dependencies: '@babel/helper-annotate-as-pure': 7.25.7 '@babel/helper-module-imports': 7.24.7(supports-color@5.5.0) - '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.25.2) + '@babel/plugin-syntax-jsx': 7.25.7(@babel/core@7.25.2) lodash: 4.17.21 picomatch: 2.3.1 styled-components: 5.3.9(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1) @@ -12568,6 +12747,12 @@ snapshots: readable-stream: 2.3.8 typedarray: 0.0.6 + connect-pg-simple@10.0.0: + dependencies: + pg: 8.12.0 + transitivePeerDependencies: + - pg-native + consola@2.15.3: {} console-control-strings@1.1.0: {} @@ -12582,6 +12767,11 @@ snapshots: convert-source-map@2.0.0: {} + cookie-parser@1.4.7: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + cookie-signature@1.0.6: {} cookie-signature@1.0.7: {} @@ -12590,6 +12780,10 @@ snapshots: cookie@0.6.0: {} + cookie@0.7.2: {} + + cookie@1.0.2: {} + cookiejar@2.1.4: {} core-js-compat@3.38.1: @@ -13152,7 +13346,7 @@ snapshots: '@typescript-eslint/parser': 7.18.0(eslint@8.57.0)(typescript@5.4.5) eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0) eslint-plugin-import: 2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0) eslint-plugin-jsx-a11y: 6.10.0(eslint@8.57.0) eslint-plugin-react: 7.35.2(eslint@8.57.0) @@ -13176,13 +13370,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0): + eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.3.6(supports-color@5.5.0) enhanced-resolve: 5.17.1 eslint: 8.57.0 - eslint-module-utils: 2.9.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.9.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.8.0 is-bun-module: 1.1.0 @@ -13195,14 +13389,14 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.9.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0): + eslint-module-utils@2.9.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 7.18.0(eslint@8.57.0)(typescript@5.4.5) eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0) transitivePeerDependencies: - supports-color @@ -13217,7 +13411,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.9.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.9.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -13608,7 +13802,7 @@ snapshots: transitivePeerDependencies: - supports-color - financejs@4.1.0: {} + financial@0.2.4: {} find-cache-dir@2.1.0: dependencies: @@ -14168,7 +14362,7 @@ snapshots: istanbul-lib-instrument@5.2.1: dependencies: '@babel/core': 7.25.2 - '@babel/parser': 7.25.6 + '@babel/parser': 7.25.7 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 6.3.1 @@ -14178,7 +14372,7 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: '@babel/core': 7.25.2 - '@babel/parser': 7.25.6 + '@babel/parser': 7.25.7 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 7.6.3 @@ -15127,6 +15321,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.0.0 + obuf@1.1.2: {} + oidc-token-hash@5.0.3: {} on-finished@2.4.1: @@ -15217,7 +15413,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.24.7 + '@babel/code-frame': 7.25.7 error-ex: 1.3.2 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -15288,6 +15484,8 @@ snapshots: pg-int8@1.0.1: {} + pg-numeric@1.0.2: {} + pg-pool@3.6.2(pg@8.12.0): dependencies: pg: 8.12.0 @@ -15302,6 +15500,16 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 + pg-types@4.0.2: + dependencies: + pg-int8: 1.0.1 + pg-numeric: 1.0.2 + postgres-array: 3.0.2 + postgres-bytea: 3.0.0 + postgres-date: 2.1.0 + postgres-interval: 3.0.0 + postgres-range: 1.1.4 + pg@8.12.0: dependencies: pg-connection-string: 2.6.4 @@ -15402,14 +15610,26 @@ snapshots: postgres-array@2.0.0: {} + postgres-array@3.0.2: {} + postgres-bytea@1.0.0: {} + postgres-bytea@3.0.0: + dependencies: + obuf: 1.1.2 + postgres-date@1.0.7: {} + postgres-date@2.1.0: {} + postgres-interval@1.2.0: dependencies: xtend: 4.0.2 + postgres-interval@3.0.0: {} + + postgres-range@1.1.4: {} + potpack@2.0.0: {} preact-render-to-string@5.2.6(preact@10.24.2): @@ -15614,6 +15834,10 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 + react-country-flag@3.1.0(react@18.3.1): + dependencies: + react: 18.3.1 + react-currency-input-field@3.8.0(react@18.3.1): dependencies: react: 18.3.1 @@ -16288,7 +16512,7 @@ snapshots: styled-components@5.3.9(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1): dependencies: '@babel/helper-module-imports': 7.24.7(supports-color@5.5.0) - '@babel/traverse': 7.25.6(supports-color@5.5.0) + '@babel/traverse': 7.25.7(supports-color@5.5.0) '@emotion/is-prop-valid': 1.3.1 '@emotion/stylis': 0.8.5 '@emotion/unitless': 0.7.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 53210b18..61972c7e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,7 +4,7 @@ packages: - 'shared/**' - 'e2e/**' - 'data/**' - - 'admin/**' + - 'backoffice/**' catalog: diff --git a/shared/config/.env.test b/shared/config/.env.test index 968fd785..5e2e689b 100644 --- a/shared/config/.env.test +++ b/shared/config/.env.test @@ -26,4 +26,8 @@ EMAIL_CONFIRMATION_TOKEN_EXPIRES_IN=2h AWS_SES_ACCESS_KEY_ID=test AWS_SES_ACCESS_KEY_SECRET=test AWS_SES_DOMAIN=test -AWS_REGION=test \ No newline at end of file +AWS_REGION=test + +# Adminjs cookie configuration +BACKOFFICE_SESSION_COOKIE_NAME=backoffice +BACKOFFICE_SESSION_COOKIE_SECRET=backoffice-cookie-secret \ No newline at end of file diff --git a/shared/contracts/admin.contract.ts b/shared/contracts/admin.contract.ts index 3e3a1365..3129b936 100644 --- a/shared/contracts/admin.contract.ts +++ b/shared/contracts/admin.contract.ts @@ -21,4 +21,12 @@ export const adminContract = contract.router({ }, body: contract.type(), }, + uploadProjectScorecard: { + method: "POST", + path: "/admin/upload/scorecard", + responses: { + 201: contract.type(), + }, + body: contract.type(), + }, }); diff --git a/shared/contracts/custom-projects.contract.ts b/shared/contracts/custom-projects.contract.ts index 7a080e5d..464dec7c 100644 --- a/shared/contracts/custom-projects.contract.ts +++ b/shared/contracts/custom-projects.contract.ts @@ -4,9 +4,6 @@ import { Country } from "@shared/entities/country.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"; @@ -47,13 +44,13 @@ export const customProjectContract = contract.router({ }, body: contract.type(), }, - snapshotCustomProject: { + saveCustomProject: { method: "POST", - path: "/custom-projects/snapshots", + path: "/custom-projects/save", responses: { 201: contract.type(), }, - body: contract.type(), + body: contract.type(), }, }); diff --git a/shared/dtos/custom-projects/cost.inputs.ts b/shared/dtos/custom-projects/cost.inputs.ts index 96ccbc86..d92b6bfa 100644 --- a/shared/dtos/custom-projects/cost.inputs.ts +++ b/shared/dtos/custom-projects/cost.inputs.ts @@ -1,4 +1,54 @@ -import { BaseDataView } from "@shared/entities/base-data.view"; +import { IsNumber } from "class-validator"; // TODO: We have a class-validator DTO in the backend for this class, what we need to do is to create a zod schema so that we validate it in the contract // and potentially in the DTO + +export class OverridableCostInputs { + @IsNumber() + financingCost: number; + + @IsNumber() + monitoring: number; + + @IsNumber() + maintenance: number; + + @IsNumber() + communityBenefitSharingFund: number; + + @IsNumber() + carbonStandardFees: number; + + @IsNumber() + baselineReassessment: number; + + @IsNumber() + mrv: number; + + @IsNumber() + longTermProjectOperatingCost: number; + + @IsNumber() + feasibilityAnalysis: number; + + @IsNumber() + conservationPlanningAndAdmin: number; + + @IsNumber() + dataCollectionAndFieldCost: number; + + @IsNumber() + communityRepresentation: number; + + @IsNumber() + blueCarbonProjectPlanning: number; + + @IsNumber() + establishingCarbonRights: number; + + @IsNumber() + validation: number; + + @IsNumber() + implementationLabor: number; +} diff --git a/shared/dtos/custom-projects/custom-project-output.dto.ts b/shared/dtos/custom-projects/custom-project-output.dto.ts new file mode 100644 index 00000000..bc53fe17 --- /dev/null +++ b/shared/dtos/custom-projects/custom-project-output.dto.ts @@ -0,0 +1,91 @@ +import { OverridableCostInputs } from "@shared/dtos/custom-projects/cost.inputs"; + +export type CustomProjectSummary = { + "$/tCO2e (total cost, NPV)": number; + "$/ha": number; + "NPV covering cost": number; + "Leftover after OpEx / total cost": number | null; + "IRR when priced to cover OpEx": number; + "IRR when priced to cover total cost": number; + "Total cost (NPV)": number; + "Capital expenditure (NPV)": number; + "Operating expenditure (NPV)": number; + "Credits issued": number; + "Total revenue (NPV)": number; + "Total revenue (non-discounted)": number; + "Financing cost": number; + "Funding gap": number; + "Funding gap (NPV)": number; + "Funding gap per tCO2e (NPV)": number; + "Community benefit sharing fund": number; +}; + +export type CustomProjectCostDetails = { + capitalExpenditure: number; + operationalExpenditure: number; + totalCost: number; + feasibilityAnalysis: number; + conservationPlanningAndAdmin: number; + dataCollectionAndFieldCost: number; + communityRepresentation: number; + blueCarbonProjectPlanning: number; + establishingCarbonRights: number; + validation: number; + implementationLabor: number; + operationExpenditure: number; + monitoring: number; + maintenance: number; + communityBenefitSharingFund: number; + carbonStandardFees: number; + baselineReassessment: number; + mrv: number; + longTermProjectOperatingCost: number; +}; + +export type YearlyBreakdown = { + costName: keyof OverridableCostInputs; + totalCost: number; + totalNPV: number; + costValues: CostPlanMap; +}; + +export type CostPlanMap = { + [year: number]: number; +}; + +export type CustomProjectOutput = + | ConservationProjectOutput + | RestorationProjectOutput; + +export class RestorationProjectOutput { + // to be defined. it will share most of the props, but probably carbon input related fields will be different + // i.e conservation does not account for sequestration rate, but restoration does + // Restoration does not care about emission factors, but conservation does +} + +export class ConservationProjectOutput { + lossRate: number; + emissionFactors: { + emissionFactor: number; + emissionFactorAgb: number; + emissionFactorSoc: number; + }; + totalProjectCost: { + total: { + total: number; + capex: number; + opex: number; + }; + npv: { + total: number; + capex: number; + opex: number; + }; + }; + summary: CustomProjectSummary; + costDetails: { + total: CustomProjectCostDetails; + npv: CustomProjectCostDetails; + }; + yearlyBreakdown: YearlyBreakdown[]; +} diff --git a/shared/dtos/users/user.dto.ts b/shared/dtos/users/user.dto.ts index c92b0fc1..61cee197 100644 --- a/shared/dtos/users/user.dto.ts +++ b/shared/dtos/users/user.dto.ts @@ -1,9 +1,11 @@ import { OmitType } from "@nestjs/mapped-types"; +import { BackOfficeSession } from "@shared/entities/users/backoffice-session"; import { User } from "@shared/entities/users/user.entity"; export type UserWithAccessToken = { user: UserDto; accessToken: string; + backofficeSession?: BackOfficeSession }; export class UserDto extends OmitType(User, ["password"]) {} diff --git a/shared/entities/carbon-inputs/ecosystem-extent.entity.ts b/shared/entities/carbon-inputs/ecosystem-extent.entity.ts index 6404701e..623130ea 100644 --- a/shared/entities/carbon-inputs/ecosystem-extent.entity.ts +++ b/shared/entities/carbon-inputs/ecosystem-extent.entity.ts @@ -20,6 +20,9 @@ export class EcosystemExtent extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column({ name: "ecosystem", enum: ECOSYSTEM, type: "enum" }) ecosystem: ECOSYSTEM; diff --git a/shared/entities/carbon-inputs/ecosystem-loss.entity.ts b/shared/entities/carbon-inputs/ecosystem-loss.entity.ts index 975a7927..7e34195b 100644 --- a/shared/entities/carbon-inputs/ecosystem-loss.entity.ts +++ b/shared/entities/carbon-inputs/ecosystem-loss.entity.ts @@ -20,6 +20,9 @@ export class EcosystemLoss extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column({ name: "ecosystem", enum: ECOSYSTEM, type: "enum" }) ecosystem: ECOSYSTEM; diff --git a/shared/entities/carbon-inputs/emission-factors.entity.ts b/shared/entities/carbon-inputs/emission-factors.entity.ts index a0c92443..99f9459c 100644 --- a/shared/entities/carbon-inputs/emission-factors.entity.ts +++ b/shared/entities/carbon-inputs/emission-factors.entity.ts @@ -29,6 +29,9 @@ export class EmissionFactors extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column({ name: "ecosystem", enum: ECOSYSTEM, type: "enum" }) ecosystem: ECOSYSTEM; diff --git a/shared/entities/carbon-inputs/restorable-land.entity.ts b/shared/entities/carbon-inputs/restorable-land.entity.ts index 6609dec2..d9480cdf 100644 --- a/shared/entities/carbon-inputs/restorable-land.entity.ts +++ b/shared/entities/carbon-inputs/restorable-land.entity.ts @@ -20,6 +20,9 @@ export class RestorableLand extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column({ name: "ecosystem", enum: ECOSYSTEM, type: "enum" }) ecosystem: ECOSYSTEM; diff --git a/shared/entities/carbon-inputs/sequestration-rate.entity.ts b/shared/entities/carbon-inputs/sequestration-rate.entity.ts index 28f52b10..c2617cf2 100644 --- a/shared/entities/carbon-inputs/sequestration-rate.entity.ts +++ b/shared/entities/carbon-inputs/sequestration-rate.entity.ts @@ -30,6 +30,9 @@ export class SequestrationRate extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column({ name: "ecosystem", enum: ECOSYSTEM, type: "enum" }) ecosystem: ECOSYSTEM; diff --git a/shared/entities/cost-inputs/baseline-reassessment.entity.ts b/shared/entities/cost-inputs/baseline-reassessment.entity.ts index bf459e6e..b62cee50 100644 --- a/shared/entities/cost-inputs/baseline-reassessment.entity.ts +++ b/shared/entities/cost-inputs/baseline-reassessment.entity.ts @@ -19,6 +19,9 @@ export class BaselineReassessment extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column("decimal", { name: "baseline_reassessment_cost_per_event" }) baselineReassessmentCost: number; } diff --git a/shared/entities/cost-inputs/blue-carbon-project-planning.entity.ts b/shared/entities/cost-inputs/blue-carbon-project-planning.entity.ts index 0522027c..5395e69b 100644 --- a/shared/entities/cost-inputs/blue-carbon-project-planning.entity.ts +++ b/shared/entities/cost-inputs/blue-carbon-project-planning.entity.ts @@ -27,6 +27,9 @@ export class BlueCarbonProjectPlanning extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column({ type: "enum", enum: INPUT_SELECTION, diff --git a/shared/entities/cost-inputs/carbon-standard-fees.entity.ts b/shared/entities/cost-inputs/carbon-standard-fees.entity.ts index 82e181b0..f196c691 100644 --- a/shared/entities/cost-inputs/carbon-standard-fees.entity.ts +++ b/shared/entities/cost-inputs/carbon-standard-fees.entity.ts @@ -19,6 +19,9 @@ export class CarbonStandardFees extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column("decimal", { name: "cost_per_carbon_credit_issued" }) carbonStandardFee: number; } diff --git a/shared/entities/cost-inputs/community-benefit-sharing-fund.entity.ts b/shared/entities/cost-inputs/community-benefit-sharing-fund.entity.ts index 37a70ba3..12810064 100644 --- a/shared/entities/cost-inputs/community-benefit-sharing-fund.entity.ts +++ b/shared/entities/cost-inputs/community-benefit-sharing-fund.entity.ts @@ -19,6 +19,9 @@ export class CommunityBenefitSharingFund extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column("decimal", { name: "community_benefit_sharing_fund_pc_of_revenue" }) communityBenefitSharingFund: number; } diff --git a/shared/entities/cost-inputs/community-cash-flow.entity.ts b/shared/entities/cost-inputs/community-cash-flow.entity.ts index 5349ad92..49d60fcc 100644 --- a/shared/entities/cost-inputs/community-cash-flow.entity.ts +++ b/shared/entities/cost-inputs/community-cash-flow.entity.ts @@ -24,6 +24,9 @@ export class CommunityCashFlow extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column({ type: "enum", enum: COMMUNITY_CASH_FLOW_TYPES, nullable: true }) cashflowType: COMMUNITY_CASH_FLOW_TYPES; } diff --git a/shared/entities/cost-inputs/community-representation.entity.ts b/shared/entities/cost-inputs/community-representation.entity.ts index f2c3f897..40d1b670 100644 --- a/shared/entities/cost-inputs/community-representation.entity.ts +++ b/shared/entities/cost-inputs/community-representation.entity.ts @@ -20,6 +20,9 @@ export class CommunityRepresentation extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column({ name: "ecosystem", enum: ECOSYSTEM, type: "enum" }) ecosystem: ECOSYSTEM; diff --git a/shared/entities/cost-inputs/conservation-and-planning-admin.entity.ts b/shared/entities/cost-inputs/conservation-and-planning-admin.entity.ts index 02867df7..96feea7d 100644 --- a/shared/entities/cost-inputs/conservation-and-planning-admin.entity.ts +++ b/shared/entities/cost-inputs/conservation-and-planning-admin.entity.ts @@ -20,6 +20,9 @@ export class ConservationPlanningAndAdmin extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column({ name: "ecosystem", enum: ECOSYSTEM, type: "enum" }) ecosystem: ECOSYSTEM; diff --git a/shared/entities/cost-inputs/data-collection-and-field-costs.entity.ts b/shared/entities/cost-inputs/data-collection-and-field-costs.entity.ts index 1b23afbe..e450c5cb 100644 --- a/shared/entities/cost-inputs/data-collection-and-field-costs.entity.ts +++ b/shared/entities/cost-inputs/data-collection-and-field-costs.entity.ts @@ -20,6 +20,9 @@ export class DataCollectionAndFieldCosts extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column({ name: "ecosystem", enum: ECOSYSTEM, type: "enum" }) ecosystem: ECOSYSTEM; diff --git a/shared/entities/cost-inputs/establishing-carbon-rights.entity.ts b/shared/entities/cost-inputs/establishing-carbon-rights.entity.ts index c430df40..a5d9a2d5 100644 --- a/shared/entities/cost-inputs/establishing-carbon-rights.entity.ts +++ b/shared/entities/cost-inputs/establishing-carbon-rights.entity.ts @@ -19,6 +19,9 @@ export class CarbonRights extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column("decimal", { name: "carbon_rights_cost" }) carbonRightsCost: number; } diff --git a/shared/entities/cost-inputs/feasability-analysis.entity.ts b/shared/entities/cost-inputs/feasability-analysis.entity.ts index 3e7e6c6c..3dff7406 100644 --- a/shared/entities/cost-inputs/feasability-analysis.entity.ts +++ b/shared/entities/cost-inputs/feasability-analysis.entity.ts @@ -20,6 +20,9 @@ export class FeasibilityAnalysis extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column({ name: "ecosystem", enum: ECOSYSTEM, type: "enum" }) ecosystem: ECOSYSTEM; diff --git a/shared/entities/cost-inputs/financing-cost.entity.ts b/shared/entities/cost-inputs/financing-cost.entity.ts index babe28d0..4766f8e1 100644 --- a/shared/entities/cost-inputs/financing-cost.entity.ts +++ b/shared/entities/cost-inputs/financing-cost.entity.ts @@ -19,6 +19,9 @@ export class FinancingCost extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column("decimal", { name: "financing_cost_capex_percent" }) financingCostCapexPercent: number; } diff --git a/shared/entities/cost-inputs/implementation-labor-cost.entity.ts b/shared/entities/cost-inputs/implementation-labor-cost.entity.ts index 77bfc9fa..a732c598 100644 --- a/shared/entities/cost-inputs/implementation-labor-cost.entity.ts +++ b/shared/entities/cost-inputs/implementation-labor-cost.entity.ts @@ -20,6 +20,9 @@ export class ImplementationLaborCost extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column({ name: "ecosystem", enum: ECOSYSTEM, type: "enum" }) ecosystem: ECOSYSTEM; diff --git a/shared/entities/cost-inputs/long-term-project-operating.entity.ts b/shared/entities/cost-inputs/long-term-project-operating.entity.ts index 03028c96..c0a06ada 100644 --- a/shared/entities/cost-inputs/long-term-project-operating.entity.ts +++ b/shared/entities/cost-inputs/long-term-project-operating.entity.ts @@ -20,6 +20,9 @@ export class LongTermProjectOperating extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column({ name: "ecosystem", enum: ECOSYSTEM, type: "enum" }) ecosystem: ECOSYSTEM; diff --git a/shared/entities/cost-inputs/maintenance.entity.ts b/shared/entities/cost-inputs/maintenance.entity.ts index 0d9ef713..a5dd01ba 100644 --- a/shared/entities/cost-inputs/maintenance.entity.ts +++ b/shared/entities/cost-inputs/maintenance.entity.ts @@ -19,6 +19,9 @@ export class Maintenance extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column("decimal", { name: "maintenance_cost_pc_of_impl_labor_cost" }) maintenanceCost: number; diff --git a/shared/entities/cost-inputs/monitoring.entity.ts b/shared/entities/cost-inputs/monitoring.entity.ts index df8891f9..49fa2322 100644 --- a/shared/entities/cost-inputs/monitoring.entity.ts +++ b/shared/entities/cost-inputs/monitoring.entity.ts @@ -20,6 +20,9 @@ export class MonitoringCost extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column({ name: "ecosystem", enum: ECOSYSTEM, type: "enum" }) ecosystem: ECOSYSTEM; diff --git a/shared/entities/cost-inputs/mrv.entity.ts b/shared/entities/cost-inputs/mrv.entity.ts index bc5820f5..0484860d 100644 --- a/shared/entities/cost-inputs/mrv.entity.ts +++ b/shared/entities/cost-inputs/mrv.entity.ts @@ -19,6 +19,9 @@ export class MRV extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column("decimal", { name: "mrv_cost_per_event" }) mrvCost: number; } diff --git a/shared/entities/cost-inputs/project-size.entity.ts b/shared/entities/cost-inputs/project-size.entity.ts index 4c1a9166..73c62ba5 100644 --- a/shared/entities/cost-inputs/project-size.entity.ts +++ b/shared/entities/cost-inputs/project-size.entity.ts @@ -17,16 +17,21 @@ export class ProjectSize extends BaseEntity { @PrimaryGeneratedColumn("uuid") id: string; - @ManyToOne(() => Country, (country) => country.code, { onDelete: "CASCADE" }) + @ManyToOne(() => Country, (country) => country.code, { + onDelete: "CASCADE", + }) @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column({ name: "ecosystem", enum: ECOSYSTEM, type: "enum" }) ecosystem: ECOSYSTEM; @Column({ name: "activity", enum: ACTIVITY, type: "enum" }) activity: ACTIVITY; - @Column("decimal", { name: "size" }) + @Column({ name: "size", type: "decimal" }) sizeHa: number; } diff --git a/shared/entities/cost-inputs/validation.entity.ts b/shared/entities/cost-inputs/validation.entity.ts index f7d5b843..f3ed44ed 100644 --- a/shared/entities/cost-inputs/validation.entity.ts +++ b/shared/entities/cost-inputs/validation.entity.ts @@ -19,6 +19,9 @@ export class ValidationCost extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; + @Column({ name: "country_code", type: "char", length: 3 }) + countryCode: string; + @Column("decimal", { name: "validation_cost" }) validationCost: number; } diff --git a/shared/entities/country.entity.ts b/shared/entities/country.entity.ts index 38f493c9..8b24e125 100644 --- a/shared/entities/country.entity.ts +++ b/shared/entities/country.entity.ts @@ -50,6 +50,7 @@ export class Country extends BaseEntity { srid: 4326, // TODO: Make it nullable false once we have all the data nullable: true, + select: false, }) geometry: Geometry; } diff --git a/shared/entities/custom-project.entity.ts b/shared/entities/custom-project.entity.ts index 2a28d7bc..8679aa50 100644 --- a/shared/entities/custom-project.entity.ts +++ b/shared/entities/custom-project.entity.ts @@ -1,4 +1,3 @@ -import { CustomProjectSnapshotDto } from "@api/modules/custom-projects/dto/custom-project-snapshot.dto"; import { Column, Entity, @@ -9,22 +8,49 @@ import { import { ECOSYSTEM } from "@shared/entities/ecosystem.enum"; import { ACTIVITY } from "@shared/entities/activity.enum"; import { Country } from "@shared/entities/country.entity"; +import { User } from "@shared/entities/users/user.entity"; +import { type CustomProjectOutput } from "@shared/dtos/custom-projects/custom-project-output.dto"; /** - * @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 - * in the backoffice because privacy reasons. - * - * The shape defined here is probably wrong, it's only based on the output of the prototype in the notebooks, and it will only serve as a learning resource. + * @note: This entity does not extend BaseEntity as it won't be used in the backoffice. However, it has to be added to the BO datasource due to its relation + * to other entities that (i.e User) */ +export enum CARBON_REVENUES_TO_COVER { + OPEX = "Opex", + CAPEX_AND_OPEX = "Capex and Opex", +} + @Entity({ name: "custom_projects" }) export class CustomProject { @PrimaryGeneratedColumn("uuid") - id: string; + id?: string; + + @Column({ name: "project_name", type: "varchar" }) + projectName: string; + + @Column({ name: "total_cost_npv", type: "decimal", nullable: true }) + totalCostNPV: number; + + @Column({ name: "total_cost", type: "decimal", nullable: true }) + totalCost: number; + + @Column({ name: "project_size", type: "decimal" }) + projectSize: number; + + @Column({ name: "project_length", type: "decimal" }) + projectLength: number; + + @Column({ name: "abatement_potential", type: "decimal", nullable: true }) + abatementPotential: number; + + @ManyToOne(() => User, (user) => user.customProjects, { onDelete: "CASCADE" }) + @JoinColumn({ name: "user_id" }) + user?: User; @ManyToOne(() => Country, (country) => country.code, { onDelete: "CASCADE" }) @JoinColumn({ name: "country_code" }) - countryCode: Country; + country: Country; @Column({ name: "ecosystem", enum: ECOSYSTEM, type: "enum" }) ecosystem: ECOSYSTEM; @@ -33,22 +59,9 @@ export class CustomProject { activity: ACTIVITY; @Column({ name: "input_snapshot", type: "jsonb" }) - input_snapshot: any; + // TODO: this should be the infered type of the zod schema + input: 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; - } + output: CustomProjectOutput; } diff --git a/shared/entities/project-scorecard.entity.ts b/shared/entities/project-scorecard.entity.ts index 43552aa8..f4ba29ce 100644 --- a/shared/entities/project-scorecard.entity.ts +++ b/shared/entities/project-scorecard.entity.ts @@ -12,7 +12,6 @@ import { ECOSYSTEM } from "@shared/entities/ecosystem.enum"; import { PROJECT_SCORE } from "@shared/entities/project-score.enum"; @Entity("project_scorecard") -@Unique(["country", "ecosystem"]) export class ProjectScorecard extends BaseEntity { @PrimaryGeneratedColumn("uuid") id: string; @@ -25,30 +24,52 @@ export class ProjectScorecard extends BaseEntity { @JoinColumn({ name: "country_code" }) country: Country; - @Column({ name: "ecosystem", enum: ECOSYSTEM, type: "enum" }) + @Column({ name: "ecosystem", enum: ECOSYSTEM, nullable: true, type: "enum" }) ecosystem: ECOSYSTEM; - @Column({ name: "financial_feasibility", enum: PROJECT_SCORE, type: "enum" }) + @Column({ + name: "financial_feasibility", + nullable: true, + enum: PROJECT_SCORE, + type: "enum", + }) financialFeasibility: PROJECT_SCORE; - @Column({ name: "legal_feasibility", enum: PROJECT_SCORE, type: "enum" }) + @Column({ + name: "legal_feasibility", + nullable: true, + enum: PROJECT_SCORE, + type: "enum", + }) legalFeasibility: PROJECT_SCORE; @Column({ name: "implementation_feasibility", + nullable: true, enum: PROJECT_SCORE, type: "enum", }) implementationFeasibility: PROJECT_SCORE; - @Column({ name: "social_feasibility", enum: PROJECT_SCORE, type: "enum" }) + @Column({ + name: "social_feasibility", + nullable: true, + enum: PROJECT_SCORE, + type: "enum", + }) socialFeasibility: PROJECT_SCORE; - @Column({ name: "security_rating", enum: PROJECT_SCORE, type: "enum" }) + @Column({ + name: "security_rating", + nullable: true, + enum: PROJECT_SCORE, + type: "enum", + }) securityRating: PROJECT_SCORE; @Column({ name: "availability_of_experienced_labor", + nullable: true, enum: PROJECT_SCORE, type: "enum", }) @@ -56,6 +77,7 @@ export class ProjectScorecard extends BaseEntity { @Column({ name: "availability_of_alternating_funding", + nullable: true, enum: PROJECT_SCORE, type: "enum", }) @@ -63,11 +85,17 @@ export class ProjectScorecard extends BaseEntity { @Column({ name: "coastal_protection_benefits", + nullable: true, enum: PROJECT_SCORE, type: "enum", }) coastalProtectionBenefits: PROJECT_SCORE; - @Column({ name: "biodiversity_benefit", enum: PROJECT_SCORE, type: "enum" }) + @Column({ + name: "biodiversity_benefit", + nullable: true, + enum: PROJECT_SCORE, + type: "enum", + }) biodiversityBenefit: PROJECT_SCORE; } diff --git a/shared/entities/projects.entity.ts b/shared/entities/projects.entity.ts index 0d352176..13dd5eaf 100644 --- a/shared/entities/projects.entity.ts +++ b/shared/entities/projects.entity.ts @@ -52,8 +52,8 @@ export class Project extends BaseEntity { // TODO: We need to make this a somehow enum, as a subactivity of restoration, that can be null for conservation, and can represent all restoration activities @Column({ name: "restoration_activity", - type: "varchar", - length: 255, + type: "enum", + enum: RESTORATION_ACTIVITY_SUBTYPE, nullable: true, }) restorationActivity: RESTORATION_ACTIVITY_SUBTYPE; @@ -61,7 +61,6 @@ export class Project extends BaseEntity { @Column({ name: "project_size", type: "decimal" }) projectSize: number; - // TODO: We could potentially remove this column from the database and excel, and have a threshold to filter by @Column({ name: "project_size_filter", type: "enum", diff --git a/shared/entities/users/backoffice-session.ts b/shared/entities/users/backoffice-session.ts new file mode 100644 index 00000000..12fc82ce --- /dev/null +++ b/shared/entities/users/backoffice-session.ts @@ -0,0 +1,18 @@ +import { Entity, Column, PrimaryColumn } from "typeorm"; + +export const BACKOFFICE_SESSIONS_TABLE = "backoffice_sessions"; + +@Entity(BACKOFFICE_SESSIONS_TABLE) +export class BackOfficeSession { + @PrimaryColumn("varchar") + sid: string; + + @Column("json") + sess: { + cookie: any, + adminUser: any, + }; + + @Column("timestamp", { precision: 6, nullable: true }) + expire?: Date; +} diff --git a/shared/entities/users/user.entity.ts b/shared/entities/users/user.entity.ts index 16727524..54aa4ff3 100644 --- a/shared/entities/users/user.entity.ts +++ b/shared/entities/users/user.entity.ts @@ -11,6 +11,7 @@ import { ROLES } from "@shared/entities/users/roles.enum"; import { UserUploadCostInputs } from "@shared/entities/users/user-upload-cost-inputs.entity"; import { UserUploadRestorationInputs } from "@shared/entities/users/user-upload-restoration-inputs.entity"; import { UserUploadConservationInputs } from "@shared/entities/users/user-upload-conservation-inputs.entity"; +import { CustomProject } from "@shared/entities/custom-project.entity"; // TODO: For future reference: // https://github.com/typeorm/typeorm/issues/2897 @@ -55,4 +56,7 @@ export class User extends BaseEntity { @OneToMany("UserUploadConservationInputs", "user") userUploadConservationInputs: UserUploadConservationInputs[]; + + @OneToMany("CustomProject", "user") + customProjects: CustomProject[]; } diff --git a/shared/lib/db-entities.ts b/shared/lib/db-entities.ts index e791725c..01dadcaf 100644 --- a/shared/lib/db-entities.ts +++ b/shared/lib/db-entities.ts @@ -35,6 +35,7 @@ import { UserUploadRestorationInputs } from "@shared/entities/users/user-upload- import { UserUploadConservationInputs } from "@shared/entities/users/user-upload-conservation-inputs.entity"; import { ProjectScorecard } from "@shared/entities/project-scorecard.entity"; import { ProjectScorecardView } from "@shared/entities/project-scorecard.view"; +import { BackOfficeSession } from "@shared/entities/users/backoffice-session"; export const COMMON_DATABASE_ENTITIES = [ User, @@ -74,4 +75,5 @@ export const COMMON_DATABASE_ENTITIES = [ UserUploadConservationInputs, ProjectScorecard, ProjectScorecardView, + BackOfficeSession, ]; diff --git a/shared/schemas/custom-projects/get-cost-inputs.schema.ts b/shared/schemas/custom-projects/get-cost-inputs.schema.ts index a67538f9..e007be7d 100644 --- a/shared/schemas/custom-projects/get-cost-inputs.schema.ts +++ b/shared/schemas/custom-projects/get-cost-inputs.schema.ts @@ -29,9 +29,8 @@ export const GetDefaultCostInputsSchema = z data.restorationActivity === undefined ) { ctx.addIssue({ - path: ["restorationActivitySubtype"], - message: - "restorationActivitySubtype is required when activity is RESTORATION", + path: ["restorationActivity"], + message: "restorationActivity is required when activity is RESTORATION", code: z.ZodIssueCode.custom, }); } diff --git a/tsconfig.json b/tsconfig.json index 8fcbed30..c7c145e1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ "@shared/*": ["./shared/*"], "@data/*": ["./data/*"] }, - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "jsx": "react" } } \ No newline at end of file