jobnimbus-mcp-client
Version:
JobNimbus MCP Client - Connect Claude Desktop to remote JobNimbus MCP server
313 lines • 15.9 kB
JavaScript
/**
* Analyze Revenue Leakage - Identify potential revenue leakage points
* Detects lost opportunities, delayed conversions, and pipeline inefficiencies
*/
import { BaseTool } from '../baseTool.js';
export class AnalyzeRevenueLeakageTool extends BaseTool {
get definition() {
return {
name: 'analyze_revenue_leakage',
description: 'Identify potential revenue leakage points',
inputSchema: {
type: 'object',
properties: {
lookback_days: {
type: 'number',
default: 90,
description: 'Days to analyze for leakage',
},
include_active: {
type: 'boolean',
default: true,
description: 'Include active opportunities at risk',
},
min_value_threshold: {
type: 'number',
default: 0,
description: 'Minimum deal value to include',
},
},
},
};
}
async execute(input, context) {
try {
const lookbackDays = input.lookback_days || 90;
const includeActive = input.include_active !== false;
const minValueThreshold = input.min_value_threshold || 0;
// Fetch comprehensive data
const [jobsResponse, estimatesResponse, activitiesResponse] = await Promise.all([
this.client.get(context.apiKey, 'jobs', { size: 100 }),
this.client.get(context.apiKey, 'estimates', { size: 100 }),
this.client.get(context.apiKey, 'activities', { size: 100 }),
]);
const jobs = jobsResponse.data?.results || [];
const estimates = estimatesResponse.data?.results || [];
const activities = activitiesResponse.data?.activity || activitiesResponse.data?.results || [];
// Build lookups
const estimatesByJob = new Map();
for (const estimate of estimates) {
const related = estimate.related || [];
for (const rel of related) {
if (rel.type === 'job' && rel.id) {
if (!estimatesByJob.has(rel.id)) {
estimatesByJob.set(rel.id, []);
}
estimatesByJob.get(rel.id).push(estimate);
}
}
}
const activitiesByJob = new Map();
for (const activity of activities) {
const related = activity.related || [];
for (const rel of related) {
if (rel.type === 'job' && rel.id) {
if (!activitiesByJob.has(rel.id)) {
activitiesByJob.set(rel.id, []);
}
activitiesByJob.get(rel.id).push(activity);
}
}
}
// Calculate time boundaries
const now = Date.now();
const lookbackStart = now - (lookbackDays * 24 * 60 * 60 * 1000);
// Analyze leakage sources
let totalLeakedRevenue = 0;
const leakageSources = new Map();
const conversionDelays = [];
const lostOpportunities = [];
for (const job of jobs) {
if (!job.jnid)
continue;
const jobDate = job.date_created || 0;
if (jobDate < lookbackStart)
continue;
const jobEstimates = estimatesByJob.get(job.jnid) || [];
const jobActivities = activitiesByJob.get(job.jnid) || [];
const statusName = (job.status_name || '').toLowerCase();
// Calculate job value
let jobValue = 0;
let hasApprovedEstimate = false;
let oldestPendingEstimate = null;
for (const estimate of jobEstimates) {
const estimateValue = parseFloat(estimate.total || 0) || 0;
if (estimateValue < minValueThreshold)
continue;
const estimateStatus = (estimate.status_name || '').toLowerCase();
const isSigned = estimate.date_signed > 0;
const isApproved = isSigned || estimateStatus === 'approved' || estimateStatus === 'signed';
if (isApproved) {
hasApprovedEstimate = true;
jobValue = estimateValue;
}
else if (estimate.date_sent > 0) {
// Pending estimate
if (!oldestPendingEstimate || estimate.date_sent < oldestPendingEstimate.date_sent) {
oldestPendingEstimate = estimate;
jobValue = estimateValue;
}
}
}
// Identify leakage sources
// 1. Lost/Cancelled Jobs
if (statusName.includes('lost') ||
statusName.includes('cancelled') ||
statusName.includes('declined')) {
const daysInPipeline = Math.floor((now - jobDate) / (1000 * 60 * 60 * 24));
lostOpportunities.push({
job_id: job.jnid,
job_number: job.number || 'Unknown',
customer_name: job.display_name || job.first_name || 'Unknown',
estimated_value: jobValue,
lost_date: new Date(jobDate).toISOString(),
reason: job.status_name,
days_in_pipeline: daysInPipeline,
});
totalLeakedRevenue += jobValue;
this.addToLeakageSource(leakageSources, 'Lost/Cancelled Jobs', jobValue, job);
}
// 2. Estimates Sent But Not Approved (Conversion Delays)
else if (oldestPendingEstimate && includeActive) {
const daysPending = Math.floor((now - oldestPendingEstimate.date_sent) / (1000 * 60 * 60 * 24));
let delayCategory;
if (daysPending > 30) {
delayCategory = 'Extreme';
}
else if (daysPending > 14) {
delayCategory = 'High';
}
else {
delayCategory = 'Medium';
}
conversionDelays.push({
job_id: job.jnid,
job_number: job.number || 'Unknown',
customer_name: job.display_name || job.first_name || 'Unknown',
estimate_sent_date: new Date(oldestPendingEstimate.date_sent).toISOString(),
days_pending: daysPending,
estimate_value: jobValue,
status: job.status_name || 'Unknown',
delay_category: delayCategory,
});
if (daysPending > 14) {
totalLeakedRevenue += jobValue;
this.addToLeakageSource(leakageSources, 'Delayed Conversions (>14 days)', jobValue, job);
}
}
// 3. No Estimate Sent (Inactive Opportunities)
else if (jobEstimates.length === 0 && !hasApprovedEstimate && includeActive) {
const lastActivity = jobActivities.length > 0
? Math.max(...jobActivities.map(a => a.date_created || 0))
: jobDate;
const daysSinceActivity = Math.floor((now - lastActivity) / (1000 * 60 * 60 * 24));
if (daysSinceActivity > 7) {
// Estimate conservative value based on team average
const estimatedValue = 5000; // Could be calculated from team averages
totalLeakedRevenue += estimatedValue * 0.3; // 30% probability
this.addToLeakageSource(leakageSources, 'No Estimate Sent (Inactive)', estimatedValue * 0.3, job);
}
}
// 4. Abandoned After Initial Contact
else if (jobActivities.length <= 1 && !hasApprovedEstimate && includeActive) {
const daysSinceCreation = Math.floor((now - jobDate) / (1000 * 60 * 60 * 24));
if (daysSinceCreation > 3 && daysSinceCreation < 30) {
const estimatedValue = 5000;
totalLeakedRevenue += estimatedValue * 0.2; // 20% probability
this.addToLeakageSource(leakageSources, 'Abandoned After Initial Contact', estimatedValue * 0.2, job);
}
}
}
// Build leakage sources array
const leakageSourcesArray = Array.from(leakageSources.entries())
.map(([source, data]) => {
const percentage = totalLeakedRevenue > 0 ? (data.revenue / totalLeakedRevenue) * 100 : 0;
let severity;
if (percentage > 40)
severity = 'Critical';
else if (percentage > 25)
severity = 'High';
else if (percentage > 10)
severity = 'Medium';
else
severity = 'Low';
return {
source,
description: this.getLeakageDescription(source),
count: data.count,
potential_revenue: data.revenue,
percentage_of_total: percentage,
severity,
time_frame: `Last ${lookbackDays} days`,
};
})
.sort((a, b) => b.potential_revenue - a.potential_revenue);
// Sort delays by severity
conversionDelays.sort((a, b) => b.days_pending - a.days_pending);
// Sort lost opportunities by value
lostOpportunities.sort((a, b) => b.estimated_value - a.estimated_value);
return {
data_source: 'Live JobNimbus API data',
analysis_timestamp: new Date().toISOString(),
analysis_period: {
lookback_days: lookbackDays,
start_date: new Date(lookbackStart).toISOString(),
end_date: new Date(now).toISOString(),
},
summary: {
total_revenue_at_risk: totalLeakedRevenue,
total_leakage_sources: leakageSources.size,
lost_opportunities: lostOpportunities.length,
delayed_conversions: conversionDelays.length,
critical_issues: leakageSourcesArray.filter(s => s.severity === 'Critical').length,
high_priority_issues: leakageSourcesArray.filter(s => s.severity === 'High').length,
},
leakage_sources: leakageSourcesArray,
conversion_delays: conversionDelays.slice(0, 15),
lost_opportunities: lostOpportunities.slice(0, 15),
insights: this.generateInsights(leakageSourcesArray, conversionDelays, lostOpportunities),
action_plan: this.generateActionPlan(leakageSourcesArray, conversionDelays, totalLeakedRevenue),
};
}
catch (error) {
return {
error: error instanceof Error ? error.message : 'Unknown error',
status: 'Failed',
};
}
}
addToLeakageSource(sources, source, revenue, job) {
if (!sources.has(source)) {
sources.set(source, { count: 0, revenue: 0, jobs: [] });
}
const data = sources.get(source);
data.count += 1;
data.revenue += revenue;
data.jobs.push(job);
}
getLeakageDescription(source) {
const descriptions = {
'Lost/Cancelled Jobs': 'Jobs that were lost or cancelled after initial engagement',
'Delayed Conversions (>14 days)': 'Estimates pending approval for more than 2 weeks',
'No Estimate Sent (Inactive)': 'Jobs with no estimate sent and no recent activity',
'Abandoned After Initial Contact': 'Jobs with minimal follow-up after first contact',
};
return descriptions[source] || source;
}
generateInsights(sources, delays, lost) {
const insights = [];
// Primary leakage source
if (sources.length > 0) {
const primary = sources[0];
insights.push(`${primary.source} is the primary leakage source (${primary.percentage_of_total.toFixed(1)}% of total at-risk revenue)`);
}
// Conversion efficiency
const extremeDelays = delays.filter(d => d.delay_category === 'Extreme').length;
if (extremeDelays > 0) {
insights.push(`${extremeDelays} estimate(s) have been pending for >30 days - urgent follow-up needed`);
}
// Lost opportunity patterns
if (lost.length > 0) {
const avgDaysInPipeline = lost.reduce((sum, l) => sum + l.days_in_pipeline, 0) / lost.length;
insights.push(`Average time in pipeline before loss: ${avgDaysInPipeline.toFixed(0)} days`);
}
// Revenue concentration
const criticalSources = sources.filter(s => s.severity === 'Critical');
if (criticalSources.length > 0) {
insights.push(`${criticalSources.length} critical leakage source(s) identified requiring immediate attention`);
}
return insights;
}
generateActionPlan(sources, delays, totalAtRisk) {
const actions = [];
// Prioritized actions based on severity
const criticalSources = sources.filter(s => s.severity === 'Critical');
if (criticalSources.length > 0) {
actions.push(`IMMEDIATE: Address ${criticalSources[0].source} - potential recovery of $${criticalSources[0].potential_revenue.toFixed(2)}`);
}
// Conversion delays
const extremeDelays = delays.filter(d => d.delay_category === 'Extreme');
if (extremeDelays.length > 0) {
const delayValue = extremeDelays.reduce((sum, d) => sum + d.estimate_value, 0);
actions.push(`URGENT: Follow up on ${extremeDelays.length} extremely delayed estimate(s) worth $${delayValue.toFixed(2)}`);
}
// Process improvements
if (sources.some(s => s.source.includes('No Estimate Sent'))) {
actions.push('PROCESS: Implement automated reminders for jobs without estimates after 48 hours');
}
if (sources.some(s => s.source.includes('Abandoned'))) {
actions.push('PROCESS: Strengthen initial follow-up protocols within 24 hours of contact');
}
// Training needs
const highDelayRate = delays.length > sources.reduce((sum, s) => sum + s.count, 0) * 0.3;
if (highDelayRate) {
actions.push('TRAINING: Sales team needs training on faster estimate turnaround and follow-up');
}
// Potential recovery
if (totalAtRisk > 0) {
actions.push(`GOAL: Recover 25-50% of at-risk revenue through targeted interventions ($${(totalAtRisk * 0.25).toFixed(2)} - $${(totalAtRisk * 0.5).toFixed(2)})`);
}
return actions;
}
}
//# sourceMappingURL=analyzeRevenueLeakage.js.map