Build a Revenue Calculator App in DenchClaw
Build a revenue calculator app in DenchClaw that models ARR growth, churn, expansion, and monthly targets using your real CRM data and configurable assumptions.
Build a Revenue Calculator App in DenchClaw
Revenue planning at early-stage companies usually happens in Google Sheets, disconnected from the CRM. The result: your actuals and your model drift apart, and nobody knows which number is right. A revenue calculator app inside DenchClaw solves this — it pulls actuals from DuckDB and lets you model forward from there.
This guide builds a revenue calculator that reads your real closed deals, calculates current ARR, and lets you model growth scenarios interactively.
What You're Building#
- Current ARR/MRR calculated from closed deals in DuckDB
- Interactive sliders for growth rate, churn rate, and expansion revenue
- 12-month forward model with monthly breakdown
- Goal tracker: projected vs. target
- Export model to CSV
Step 1: App Setup#
mkdir -p ~/.openclaw-dench/workspace/apps/revenue-calculator.dench.app.dench.yaml:
name: Revenue Calculator
description: ARR modeling from real CRM data with growth scenario planning
icon: dollar-sign
version: 1.0.0
permissions:
- read:crm
display: tabStep 2: HTML Layout#
index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Revenue Calculator</title>
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; padding: 20px; display: grid; grid-template-columns: 320px 1fr; gap: 20px; min-height: 100vh; }
.controls { display: flex; flex-direction: column; gap: 16px; }
.card { background: #1e293b; border-radius: 12px; padding: 16px; }
h3 { font-size: 12px; color: #64748b; text-transform: uppercase; margin: 0 0 12px; }
.metric-big { font-size: 32px; font-weight: 800; line-height: 1; margin-bottom: 4px; }
.metric-label { font-size: 12px; color: #64748b; }
.slider-group { margin-bottom: 14px; }
.slider-header { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 6px; }
.slider-label { color: #94a3b8; }
.slider-value { font-weight: 700; color: #6366f1; }
input[type="range"] { width: 100%; accent-color: #6366f1; }
.model-table { width: 100%; border-collapse: collapse; }
.model-table th { font-size: 11px; color: #64748b; text-transform: uppercase; padding: 8px 12px; text-align: right; }
.model-table th:first-child { text-align: left; }
.model-table td { padding: 8px 12px; border-top: 1px solid #334155; font-size: 13px; text-align: right; }
.model-table td:first-child { text-align: left; color: #94a3b8; }
.model-table tr.highlight { background: #6366f110; }
.model-table tr.highlight td { font-weight: 700; }
.chart-container { width: 100%; height: 200px; position: relative; margin-top: 16px; }
canvas { width: 100% !important; height: 200px !important; }
button { padding: 10px 16px; background: #334155; color: #94a3b8; border: none; border-radius: 8px; cursor: pointer; font-size: 13px; width: 100%; }
.arr-context { font-size: 12px; color: #64748b; margin-top: 8px; }
</style>
</head>
<body>
<div class="controls">
<div class="card">
<h3>Current State (from CRM)</h3>
<div class="metric-big" id="currentARR" style="color:#10b981">—</div>
<div class="metric-label">Estimated ARR</div>
<div class="arr-context" id="arrContext"></div>
</div>
<div class="card">
<h3>Growth Assumptions</h3>
<div class="slider-group">
<div class="slider-header"><span class="slider-label">New MRR / month</span><span class="slider-value" id="v-new-mrr">$0</span></div>
<input type="range" id="s-new-mrr" min="0" max="50000" step="500" value="5000" oninput="updateModel()">
</div>
<div class="slider-group">
<div class="slider-header"><span class="slider-label">Monthly churn rate</span><span class="slider-value" id="v-churn">2%</span></div>
<input type="range" id="s-churn" min="0" max="15" step="0.5" value="2" oninput="updateModel()">
</div>
<div class="slider-group">
<div class="slider-header"><span class="slider-label">Expansion revenue (% of base)</span><span class="slider-value" id="v-expansion">0%</span></div>
<input type="range" id="s-expansion" min="0" max="20" step="0.5" value="0" oninput="updateModel()">
</div>
<div class="slider-group">
<div class="slider-header"><span class="slider-label">Annual target ARR</span><span class="slider-value" id="v-target">$1M</span></div>
<input type="range" id="s-target" min="100000" max="10000000" step="50000" value="1000000" oninput="updateModel()">
</div>
</div>
<button onclick="exportCSV()">Export to CSV</button>
</div>
<div>
<div class="card" style="margin-bottom:16px">
<h3>12-Month Revenue Model</h3>
<div class="chart-container">
<canvas id="revenueChart"></canvas>
</div>
</div>
<div class="card">
<h3>Monthly Breakdown</h3>
<table class="model-table">
<thead><tr><th>Month</th><th>MRR</th><th>ARR Run Rate</th><th>vs. Target</th></tr></thead>
<tbody id="modelTable"></tbody>
</table>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="calculator.js"></script>
</body>
</html>Step 3: Calculator Logic#
calculator.js:
let currentMRR = 0;
let chart = null;
function fmt(n) {
if (n >= 1000000) return '$' + (n / 1000000).toFixed(2) + 'M';
if (n >= 1000) return '$' + (n / 1000).toFixed(0) + 'K';
return '$' + Math.round(n).toLocaleString();
}
function pct(actual, target) {
const p = Math.round(actual / target * 100);
const color = p >= 100 ? '#10b981' : p >= 75 ? '#f59e0b' : '#ef4444';
return `<span style="color:${color}">${p}%</span>`;
}
async function loadCurrentARR() {
// Calculate MRR from deals closed in the last 30 days
const recentRevenue = await dench.db.query(`
SELECT SUM(CAST("Value" AS DOUBLE)) AS total
FROM v_deals
WHERE "Stage" = 'Closed Won'
AND "Close Date" >= CURRENT_DATE - INTERVAL '30 days'
`);
// Also get all-time customer count
const customerCount = await dench.db.query(`
SELECT COUNT(*) AS count FROM v_people WHERE "Status" = 'Customer'
`);
const monthlyRevenue = recentRevenue[0]?.total || 0;
currentMRR = monthlyRevenue;
document.getElementById('currentARR').textContent = fmt(currentMRR * 12);
document.getElementById('arrContext').innerHTML = `
Based on ${fmt(currentMRR)} MRR (deals closed last 30 days)<br>
${customerCount[0]?.count || 0} active customers in CRM
`;
// Pre-fill new MRR slider with current trend
const slider = document.getElementById('s-new-mrr');
slider.value = Math.max(500, Math.min(monthlyRevenue, 50000));
updateModel();
}
function updateModel() {
const newMRR = Number(document.getElementById('s-new-mrr').value);
const churnPct = Number(document.getElementById('s-churn').value) / 100;
const expansionPct = Number(document.getElementById('s-expansion').value) / 100;
const target = Number(document.getElementById('s-target').value);
// Update slider display values
document.getElementById('v-new-mrr').textContent = fmt(newMRR);
document.getElementById('v-churn').textContent = (churnPct * 100).toFixed(1) + '%';
document.getElementById('v-expansion').textContent = (expansionPct * 100).toFixed(1) + '%';
document.getElementById('v-target').textContent = fmt(target);
// Calculate 12-month model
const months = [];
let mrr = currentMRR;
const now = new Date();
for (let i = 1; i <= 12; i++) {
const date = new Date(now);
date.setMonth(date.getMonth() + i);
const monthLabel = date.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
const churnAmount = mrr * churnPct;
const expansionAmount = mrr * expansionPct;
mrr = mrr - churnAmount + expansionAmount + newMRR;
months.push({
label: monthLabel,
mrr: Math.max(0, mrr),
arr: Math.max(0, mrr * 12)
});
}
// Render table
const targetARR = target;
document.getElementById('modelTable').innerHTML = months.map((m, i) => `
<tr ${i === 11 ? 'class="highlight"' : ''}>
<td>${m.label}${i === 11 ? ' (EOY)' : ''}</td>
<td>${fmt(m.mrr)}</td>
<td>${fmt(m.arr)}</td>
<td>${pct(m.arr, targetARR)}</td>
</tr>
`).join('');
// Render chart
const labels = months.map(m => m.label);
const arrValues = months.map(m => m.arr);
const targetLine = months.map(() => target);
if (chart) chart.destroy();
chart = new Chart(document.getElementById('revenueChart'), {
type: 'line',
data: {
labels,
datasets: [
{
label: 'Projected ARR',
data: arrValues,
borderColor: '#6366f1',
backgroundColor: 'rgba(99,102,241,0.1)',
fill: true,
tension: 0.4
},
{
label: 'Target',
data: targetLine,
borderColor: '#f59e0b',
borderDash: [5, 5],
fill: false,
pointRadius: 0
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { labels: { color: '#94a3b8', font: { size: 11 } } } },
scales: {
x: { ticks: { color: '#64748b', font: { size: 10 } }, grid: { color: '#1e293b' } },
y: {
ticks: { color: '#64748b', font: { size: 10 }, callback: v => fmt(v) },
grid: { color: '#334155' }
}
}
}
});
}
function exportCSV() {
const rows = document.querySelectorAll('#modelTable tr');
const csv = ['Month,MRR,ARR Run Rate,vs. Target']
.concat([...rows].map(r => [...r.querySelectorAll('td')].map(td => td.textContent).join(',')))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: 'revenue-model.csv' });
a.click();
}
loadCurrentARR();Frequently Asked Questions#
How does it calculate current MRR?#
It sums deal values closed in the last 30 days from your v_deals view. For more accuracy, add a Monthly Value field to your deals for subscription deals where the deal value is the total contract, not monthly.
Can I add multiple revenue scenarios?#
Add scenario tabs (conservative/base/optimistic) with different slider presets. Save scenarios to dench.store and switch between them.
How do I add actual revenue tracking month by month?#
Query your closed deals grouped by month and overlay them on the chart as an "Actuals" line. This gives you model vs. actual comparison as the year progresses.
Does this work for non-SaaS businesses?#
Yes. For project-based businesses, adjust the model to show total bookings rather than ARR. Remove the churn slider (or set it to 0%) and focus on the new business pipeline.
Ready to try DenchClaw? Install in one command: npx denchclaw. Full setup guide →
