Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ export const configSchema = Joi.object({
STELLAR_NETWORK: Joi.string().default('testnet'),
HORIZON_URL: Joi.string().uri().allow(''),
SOROBAN_RPC_URL: Joi.string().uri().allow(''),
// Signing (#542): explicit mode — never treat missing secret as silent simulate
STELLAR_SIGNING_MODE: Joi.string().valid('simulate', 'live').default('simulate'),
STELLAR_SIGNING_PROVIDER: Joi.string().valid('env', 'kms', 'vault').default('env'),
STELLAR_SECRET_KEY: Joi.string().allow('', null),
STELLAR_TRANSFER_SECRET_KEY: Joi.string().allow('', null),
STELLAR_KMS_KEY_ID: Joi.string().allow('', null),
STELLAR_KMS_PUBLIC_KEY: Joi.string().allow('', null),
STELLAR_VAULT_KEY_PATH: Joi.string().allow('', null),

// ============================================================
// Auth Configuration
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../shared/database/prisma.service';
import { CreateTargetDto } from './dto/create-target.dto';
import { SbtiTarget } from './interfaces/sbti-target.interface';
Expand All @@ -11,15 +11,21 @@ import {
AuditEventType,
AuditAction,
} from '../audit-trail/interfaces/audit-event.interface';
import { ProgressTrackingService } from './services/progress-tracking.service';

@Injectable()
export class SbtiService {
private readonly logger = new Logger(SbtiService.name);
private dashboardCache = new Map<string, { expires: number; payload: any }>();
private readonly dashboardTtlMs = 3 * 60 * 1000;

constructor(
private readonly prisma: PrismaService,
private readonly targetValidation: TargetValidationService,
private readonly retirementService: RetirementService,
private readonly ghgProtocolService: GhgProtocolService,
private readonly auditTrailService: AuditTrailService,
private readonly progressTracking: ProgressTrackingService,
) {}
// Create SBTi target
async createTarget(dto: CreateTargetDto): Promise<SbtiTarget> {
Expand Down Expand Up @@ -102,17 +108,49 @@ export class SbtiService {
return result;
}

// SBTi progress dashboard
// SBTi progress dashboard — chart-ready aggregation via ProgressTrackingService
async getDashboard(companyId: string): Promise<any> {
// TODO: Aggregate and return chart-ready dashboard data
// Placeholder: return targets and progress
const cached = this.dashboardCache.get(companyId);
if (cached && cached.expires > Date.now()) {
return cached.payload;
}

const targets = await this.prisma.sbtiTarget.findMany({
where: { companyId },
});
const progress = await this.prisma.targetProgress.findMany({
where: { targetId: { in: targets.map((t) => t.id) } },
});
return { targets, progress };

let dataQuality: 'ok' | 'partial' = 'ok';
let retirementGaps: any[] = [];
try {
const gapResult = await this.getRetirementGap(companyId);
retirementGaps = gapResult.results || [];
if (gapResult.dataQuality === 'partial') dataQuality = 'partial';
} catch (err) {
dataQuality = 'partial';
this.logger.warn(
`getDashboard: retirement gap aggregation partial for ${companyId}: ${(err as Error).message}`,
);
}

const aggregation = this.progressTracking.aggregate(
targets as any,
progress as any,
{ dataQuality },
);

const payload = {
...aggregation,
retirementGaps,
generatedAt: new Date().toISOString(),
};
this.dashboardCache.set(companyId, {
expires: Date.now() + this.dashboardTtlMs,
payload,
});
return payload;
}

// Calculate retirements needed (retirement gap)
Expand All @@ -123,34 +161,39 @@ export class SbtiService {
});
// 2. For each target, get GHG emissions and retirements
const results = [];
let anyPartial = false;
for (const target of targets) {
// Get total emissions for the target year and scope
// (Assume ghgProtocolService has a method getTotalEmissions(companyId, year, scope))
let emissions = 0;
let partial = false;
try {
emissions = await (this.ghgProtocolService as any).getTotalEmissions(
companyId,
target.targetYear,
target.scope,
);
} catch {
// ignore
} catch (err) {
partial = true;
this.logger.warn(
`getRetirementGap emissions failed target=${target.id}: ${(err as Error).message}`,
);
}

// Get total retirements for the target year and scope
// (Assume retirementService has a method getTotalRetirements(companyId, year, scope))
let retirements = 0;
try {
retirements = await (this.retirementService as any).getTotalRetirements(
companyId,
target.targetYear,
target.scope,
);
} catch {
// ignore
} catch (err) {
partial = true;
this.logger.warn(
`getRetirementGap retirements failed target=${target.id}: ${(err as Error).message}`,
);
}

// Calculate gap
const gap = Math.max(0, emissions - retirements);
results.push({
targetId: target.id,
Expand All @@ -159,8 +202,10 @@ export class SbtiService {
emissions,
retirements,
gap,
dataQuality: partial ? 'partial' : 'ok',
});
if (partial) anyPartial = true;
}
return { results };
return { results, dataQuality: anyPartial ? 'partial' : 'ok' };
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { ProgressTrackingService } from './progress-tracking.service';

describe('ProgressTrackingService (#546)', () => {
const service = new ProgressTrackingService();
const target = {
id: 't1',
scope: '1',
status: 'VALIDATED',
baseYear: 2020,
baseYearEmissions: 1000,
targetYear: 2030,
reductionPercentage: 50,
};

it('marks on_track when actual matches linear trajectory', () => {
// Midpoint 2025 → expected 750
const status = service.classifyTrackStatus(target, 2025, 750);
expect(status).toBe('on_track');
});

it('marks behind when actual is above trajectory', () => {
expect(service.classifyTrackStatus(target, 2025, 950)).toBe('behind');
});

it('marks ahead when actual is below trajectory', () => {
expect(service.classifyTrackStatus(target, 2025, 500)).toBe('ahead');
});

it('returns unknown for missing actuals', () => {
expect(service.classifyTrackStatus(target, 2025, null)).toBe('unknown');
});

it('computes completion percentage toward reduction goal', () => {
// 25% of the 50% cut done → 50% complete
expect(service.completionPercentage(target, 875)).toBeCloseTo(25, 5);
expect(service.completionPercentage(target, 500)).toBeCloseTo(100, 5);
expect(service.completionPercentage(target, null)).toBe(0);
});

it('aggregates summary, series, and scope rollups', () => {
const dash = service.aggregate(
[target, { ...target, id: 't2', scope: '2', status: 'DRAFT' }],
[
{ targetId: 't1', reportingYear: 2024, emissions: 800 },
{ targetId: 't2', reportingYear: 2024, emissions: 400 },
],
);
expect(dash.summary.totalTargets).toBe(2);
expect(dash.summary.byStatus.VALIDATED).toBe(1);
expect(dash.summary.byStatus.DRAFT).toBe(1);
expect(dash.targets[0].series.length).toBeGreaterThan(0);
expect(dash.scopeRollups.length).toBe(2);
});
});
Loading
Loading