Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
353 changes: 343 additions & 10 deletions bun.lock

Large diffs are not rendered by default.

7,781 changes: 1,284 additions & 6,497 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ describe("Environment Config Module (Issue #272)", () => {
delete process.env.FRONTEND_URL;

jest.resetModules();
// eslint-disable-next-line @typescript-eslint/no-require-imports
const freshConfig = require("../config").config as typeof config;

expect(freshConfig.STELLAR_NETWORK).toBe("testnet");
Expand Down
9 changes: 8 additions & 1 deletion src/__tests__/investor.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import request from "supertest";
import express, { Express } from "express";
import investorRouter from "../routes/investor";
import investorRouter, { CARBON_OFFSET_FACTOR, CARBON_CREDIT_FACTOR } from "../routes/investor";
import { errorHandler } from "../middleware/errors";
import * as registry from "../lib/registry";
import * as iot from "../routes/iot";
Expand Down Expand Up @@ -139,4 +139,11 @@ describe("Investor Reporting Endpoints", () => {
expect(res.status).toBe(400);
});
});

describe("Carbon Constants", () => {
it("should export defined carbon calculation factor constants", () => {
expect(CARBON_OFFSET_FACTOR).toBe(0.05);
expect(CARBON_CREDIT_FACTOR).toBe(0.5);
});
});
});
2 changes: 1 addition & 1 deletion src/__tests__/process-exit-codes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import path from "path";

describe("process exit codes", () => {
const repoRoot = path.resolve(__dirname, "../..");

it("exits with code 1 when required env vars are missing", () => {
const result = spawnSyncWithEnv(
{
Expand All @@ -19,6 +18,7 @@ describe("process exit codes", () => {
});

it("exits with code 1 when the port is already in use", () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const http = require("http") as typeof import("http");
const port = 41000 + Math.floor(Math.random() * 1000);

Expand Down
19 changes: 0 additions & 19 deletions src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,6 @@ export async function updateImpactScore(
});
}

/**
* Narrowing guard for a failed simulation. Defined locally rather than using
* the SDK's `rpc.Api.isSimulationError` so the check stays a plain shape test
* and does not depend on that helper being present at runtime.
*/
function isSimulationError(
sim: rpc.Api.SimulateTransactionResponse,
): sim is rpc.Api.SimulateTransactionErrorResponse {
return "error" in sim && typeof sim.error === "string";
}

export async function getTotalProjects(): Promise<number> {
return withRpcConnection(async (client) => {
const contract = new Contract(REGISTRY_CONTRACT_ID);
Expand All @@ -80,14 +69,6 @@ export async function getTotalProjects(): Promise<number> {
stellarRpcTotal.inc({ operation: "simulateTransaction", result: "failure" });
throw err;
}
const sim = await client.simulateTransaction(tx);
if (isSimulationError(sim)) throw new Error(sim.error);

const retval = sim.result?.retval;
if (retval === undefined) {
throw new Error("total_projects simulation returned no result value");
}
return Number(scValToNative(retval));
});
}

Expand Down
7 changes: 3 additions & 4 deletions src/routes/admin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { Router, Request, Response, NextFunction } from "express";
import { getSolarData, getSatelliteData } from "./iot";
import { computeScores } from "../lib/scoring";
import { updateImpactScore, getTotalProjects } from "../lib/registry";
import { badRequest, parseOptionalInt, MAX_PROJECT_ID } from "../middleware/errors";
import { getTotalProjects } from "../lib/registry";
import { updateScoreForProject } from "../lib/scoreService";
import { badRequest, parseOptionalInt, MAX_PROJECT_ID, errorBody } from "../middleware/errors";
import { recordAudit, getAuditLog, auditToCsv } from "../lib/audit";
import { broadcastScoreUpdate } from "../lib/websocket";
import { tryBeginUpdate, markCompleted, markFailed } from "../lib/duplicate-detection";
Expand Down
20 changes: 14 additions & 6 deletions src/routes/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { recordScoreHistory } from "../lib/history";
import { triggerWebhooks } from "../lib/webhooks";
import { getSolarData, getSatelliteData } from "./iot";
import { computeScores } from "../lib/scoring";
import { updateImpactScore, getTotalProjects } from "../lib/registry";
import { updateImpactScore, getTotalProjects, RpcDegradedError } from "../lib/registry";
import { badRequest, MAX_PROJECT_ID } from "../middleware/errors";
import { tryBeginUpdate, markCompleted, markFailed } from "../lib/duplicate-detection";
import { withProjectLock } from "../lib/request-queue";

const router = Router();

Expand Down Expand Up @@ -39,16 +41,17 @@ router.post("/score-update", async (req: Request, res: Response) => {
}

const rawConcurrency = body.concurrency as number | undefined;
const concurrency = rawConcurrency !== undefined
? Math.min(MAX_CONCURRENCY, Math.max(1, Math.floor(rawConcurrency)))
: DEFAULT_CONCURRENCY;
const concurrency =
rawConcurrency !== undefined
? Math.min(MAX_CONCURRENCY, Math.max(1, Math.floor(rawConcurrency)))
: DEFAULT_CONCURRENCY;

const job = createJob(projectIds, concurrency);

// Fire-and-forget — caller polls /status
runJob(job, async (projectId) => {
return withProjectLock(projectId, async () => {
const { allowed, key, reason } = tryBeginUpdate(projectId);
const { allowed, reason } = tryBeginUpdate(projectId);
if (!allowed) {
return { project_id: projectId, skipped: true, skip_reason: reason };
}
Expand All @@ -62,7 +65,12 @@ router.post("/score-update", async (req: Request, res: Response) => {
} catch (updateErr) {
if (updateErr instanceof RpcDegradedError) {
recordScoreHistory(projectId, scores.credit_quality, scores.green_impact);
triggerWebhooks({ project_id: projectId, ...scores, tx_hash: "deferred", timestamp: Date.now() });
triggerWebhooks({
project_id: projectId,
...scores,
tx_hash: "deferred",
timestamp: Date.now(),
});
markCompleted(projectId);
return { project_id: projectId, tx_hash: "deferred", ...scores };
}
Expand Down
80 changes: 63 additions & 17 deletions src/routes/investor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,30 @@ import { Router, Request, Response, NextFunction } from "express";
import { getTotalProjects } from "../lib/registry";
import { getSolarData, getSatelliteData } from "./iot";
import { computeScores } from "../lib/scoring";
import { createDefaultFinancialInput, calculateNPV, calculatePaybackPeriod } from "../lib/financial";
import {
createDefaultFinancialInput,
calculateNPV,
calculatePaybackPeriod,
} from "../lib/financial";
import { getAuditLog } from "../lib/audit";
import { badRequest, MAX_PROJECT_ID } from "../middleware/errors";

/* ── Carbon Calculation Constants ──────────────────────────────────────────
* Multipliers for calculating carbon offsets and credits from solar power
* output and green impact scores, ensuring intent is explicit and factor
* changes land in one place.
*/

/**
* Multiplier to convert power output (kW) * green impact score into estimated carbon offset tonnes.
*/
export const CARBON_OFFSET_FACTOR = 0.05;

/**
* Multiplier to convert power output (kW) * green impact score into simulated carbon credits issued.
*/
export const CARBON_CREDIT_FACTOR = 0.5;

const router = Router();

// Helper to collect all project details deterministically
Expand All @@ -18,7 +38,7 @@ async function getPortfolioData() {
const satellite = getSatelliteData(id);
const scores = computeScores({ solar, satellite });
// Deterministic funding based on project ID
const funding = 250000 + (id * 150000) % 600000;
const funding = 250000 + ((id * 150000) % 600000);
// Expected output based on capacity factor
const expected_output = solar.max_power_kw * 24 * 365 * 0.2; // 20% capacity factor
const actual_output = solar.power_output_kw * 24 * 365 * 0.2;
Expand Down Expand Up @@ -57,10 +77,10 @@ router.get("/dashboard", async (_req: Request, res: Response, next: NextFunction
const totalCreditQuality = portfolio.reduce((acc, p) => acc + p.scores.credit_quality, 0);
const totalGreenImpact = portfolio.reduce((acc, p) => acc + p.scores.green_impact, 0);
const totalFunding = portfolio.reduce((acc, p) => acc + p.funding, 0);
// Formula for carbon offsets: power_output_kw * green_impact * constant factor
// Formula for carbon offsets: power_output_kw * green_impact * CARBON_OFFSET_FACTOR
const totalCarbonOffsets = portfolio.reduce(
(acc, p) => acc + p.solar.power_output_kw * p.scores.green_impact * 0.05,
0
(acc, p) => acc + p.solar.power_output_kw * p.scores.green_impact * CARBON_OFFSET_FACTOR,
0,
);

const recentAuditLogs = getAuditLog().slice(-5).reverse();
Expand Down Expand Up @@ -123,16 +143,27 @@ router.get("/financial-summary", async (_req: Request, res: Response, next: Next

// ROI = Net benefit over lifetime / installation cost
const lifetimeYears = input.project_lifetime_years;
const totalBenefits = npvResult.discounted_cash_flows.reduce((acc, cf) => acc + cf.revenue, 0);
const totalOpsCosts = npvResult.discounted_cash_flows.reduce((acc, cf) => acc + cf.maintenance_cost, 0);
const totalBenefits = npvResult.discounted_cash_flows.reduce(
(acc, cf) => acc + cf.revenue,
0,
);
const totalOpsCosts = npvResult.discounted_cash_flows.reduce(
(acc, cf) => acc + cf.maintenance_cost,
0,
);
const netBenefits = totalBenefits - totalOpsCosts + (input.salvage_value ?? 0);
const roi = input.installation_cost > 0 ? (netBenefits - input.installation_cost) / input.installation_cost : 0;
const roi =
input.installation_cost > 0
? (netBenefits - input.installation_cost) / input.installation_cost
: 0;

return {
project_id: p.id,
installation_cost: Math.round(input.installation_cost * 100) / 100,
npv: Math.round(npvResult.npv * 100) / 100,
payback_period_years: paybackResult.reaches_payback ? Math.round(paybackResult.payback_years * 10) / 10 : null,
payback_period_years: paybackResult.reaches_payback
? Math.round(paybackResult.payback_years * 10) / 10
: null,
roi_pct: Math.round(roi * 1000) / 10,
};
});
Expand All @@ -151,9 +182,15 @@ router.get("/financial-summary", async (_req: Request, res: Response, next: Next

const totalCost = projectFinancials.reduce((acc, p) => acc + p.installation_cost, 0);
const totalNpv = projectFinancials.reduce((acc, p) => acc + p.npv, 0);
const validPaybacks = projectFinancials.filter((p) => p.payback_period_years !== null) as Array<typeof projectFinancials[0] & { payback_period_years: number }>;
const avgPayback = validPaybacks.length > 0 ? validPaybacks.reduce((acc, p) => acc + p.payback_period_years, 0) / validPaybacks.length : null;
const avgRoi = projectFinancials.reduce((acc, p) => acc + p.roi_pct, 0) / projectFinancials.length;
const validPaybacks = projectFinancials.filter((p) => p.payback_period_years !== null) as Array<
(typeof projectFinancials)[0] & { payback_period_years: number }
>;
const avgPayback =
validPaybacks.length > 0
? validPaybacks.reduce((acc, p) => acc + p.payback_period_years, 0) / validPaybacks.length
: null;
const avgRoi =
projectFinancials.reduce((acc, p) => acc + p.roi_pct, 0) / projectFinancials.length;

res.json({
portfolio_financials: {
Expand Down Expand Up @@ -184,7 +221,9 @@ router.get("/compliance-report", async (_req: Request, res: Response, next: Next
}

// Carbon credits: simulated registry entry
const carbonCredits = Math.round(p.solar.power_output_kw * p.scores.green_impact * 0.5);
const carbonCredits = Math.round(
p.solar.power_output_kw * p.scores.green_impact * CARBON_CREDIT_FACTOR,
);

return {
project_id: p.id,
Expand All @@ -196,8 +235,11 @@ router.get("/compliance-report", async (_req: Request, res: Response, next: Next
});

const totalCredits = reports.reduce((acc, r) => acc + r.carbon_credits_issued, 0);
const avgGreenImpact = portfolio.length > 0 ? portfolio.reduce((acc, p) => acc + p.scores.green_impact, 0) / portfolio.length : 0;

const avgGreenImpact =
portfolio.length > 0
? portfolio.reduce((acc, p) => acc + p.scores.green_impact, 0) / portfolio.length
: 0;

let portfolioStatus: "Compliant" | "Warning" | "Non-Compliant" = "Compliant";
if (avgGreenImpact < 50) {
portfolioStatus = "Non-Compliant";
Expand Down Expand Up @@ -288,7 +330,9 @@ router.post("/custom-report", async (req: Request, res: Response, next: NextFunc
pReport.financials = {
installation_cost: Math.round(input.installation_cost * 100) / 100,
npv: Math.round(npvResult.npv * 100) / 100,
payback_period_years: paybackResult.reaches_payback ? Math.round(paybackResult.payback_years * 10) / 10 : null,
payback_period_years: paybackResult.reaches_payback
? Math.round(paybackResult.payback_years * 10) / 10
: null,
};
}

Expand All @@ -304,7 +348,9 @@ router.post("/custom-report", async (req: Request, res: Response, next: NextFunc
pReport.compliance = {
green_impact: p.scores.green_impact,
compliance_status: status,
carbon_credits_issued: Math.round(p.solar.power_output_kw * p.scores.green_impact * 0.5),
carbon_credits_issued: Math.round(
p.solar.power_output_kw * p.scores.green_impact * CARBON_CREDIT_FACTOR,
),
};
}

Expand Down
Loading