Diff -- /var/www/vhosts/3dshawn.com/crm.3dshawn.com/TEMPLATES/cf_admin_billing.html

O Operator
Diff

/var/www/vhosts/3dshawn.com/crm.3dshawn.com/TEMPLATES/cf_admin_billing.html

added on local at 2026-07-11 18:31:52

Added
+0
lines
Removed
-0
lines
Context
728
unchanged
Blobs
from 9fbcbf729cea
to 9fbcbf729cea
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
11<!-- =====================================================================
22 Admin Billing Console (company-staff only).
33 1. Page head + tutorial button
44 2. Cash / credit / outstanding / MRR tiles
55 3. Plan breakdown + subscription status counts
66 4. Recent invoices (all users) with refund-as-credit action
77 5. Accounts with non-zero credit balance
88 Cash revenue and credit consumption are reported as two separate
99 tiles -- they NEVER mix on this page.
1010===================================================================== -->
1111
1212<style>
1313 .abil-banner-row {
1414 display: grid; grid-template-columns: 1fr 1fr 1fr 1fr;
1515 gap: 14px; margin-bottom: 22px;
1616 }
1717 @media (max-width: 1100px) { .abil-banner-row { grid-template-columns: 1fr 1fr; } }
1818
1919 .abil-tile {
2020 background: var(--col-surface-1);
2121 border: 1px solid var(--col-border);
2222 border-radius: 14px;
2323 padding: 16px 18px;
2424 position: relative;
2525 overflow: hidden;
2626 }
2727 .abil-tile.tile-cash { border-color: rgba(34,197,94,0.40); }
2828 .abil-tile.tile-credit { border-color: rgba(124,58,237,0.40); }
2929 .abil-tile.tile-debt { border-color: rgba(245,158,11,0.40); }
3030 .abil-tile.tile-mrr { border-color: rgba(59,130,246,0.40); }
3131
3232 .abil-tile .lbl { font-size:10px; letter-spacing:1.5px; text-transform:uppercase; color:var(--col-text-3); font-weight:700; margin-bottom:6px; }
3333 .abil-tile .val { font-size:26px; font-weight:700; color:#fff; font-family: var(--font-mono, monospace); }
3434 .abil-tile .sub { font-size:11px; color:var(--col-text-3); margin-top:4px; }
3535 .abil-tile.tile-cash .val { color:#4ade80; }
3636 .abil-tile.tile-credit .val { color:#a78bfa; }
3737 .abil-tile.tile-debt .val { color:#fbbf24; }
3838
3939 .abil-twocol {
4040 display:grid; grid-template-columns: 1.4fr 1fr; gap:14px;
4141 margin-bottom: 22px;
4242 }
4343 @media (max-width: 1000px) { .abil-twocol { grid-template-columns: 1fr; } }
4444
4545 .abil-plan-bar {
4646 display: flex; flex-direction: column; gap: 8px;
4747 }
4848 .abil-plan-row {
4949 display: flex; align-items: center; gap: 10px;
5050 }
5151 .abil-plan-name {
5252 width: 80px; font-size: 12px; color: var(--col-text-2); font-weight: 600;
5353 }
5454 .abil-plan-track {
5555 flex: 1; height: 8px; background: var(--col-surface-3);
5656 border-radius: 999px; overflow: hidden;
5757 }
5858 .abil-plan-fill { height: 100%; border-radius: 999px; }
5959 .abil-plan-meta { font-size: 12px; color: var(--col-text-3); width: 110px; text-align: right; font-family: var(--font-mono, monospace); }
6060
6161 .abil-table { width:100%; border-collapse: separate; border-spacing:0;
6262 background: var(--col-surface-1);
6363 border: 1px solid var(--col-border);
6464 border-radius: 12px; overflow: hidden; }
6565 .abil-table thead th {
6666 background: var(--col-surface-2); padding:11px 14px;
6767 text-align: left; font-size: 10px; letter-spacing: 1.5px;
6868 text-transform: uppercase; color: var(--col-text-3); font-weight:700;
6969 border-bottom: 1px solid var(--col-border);
7070 }
7171 .abil-table tbody td {
7272 padding: 12px 14px; border-bottom: 1px solid var(--col-border);
7373 font-size: 13px; color: var(--col-text-2); vertical-align: middle;
7474 }
7575 .abil-table tbody tr:last-child td { border-bottom: none; }
7676 .abil-table .num { text-align: right; font-family: var(--font-mono, monospace); }
7777 .abil-table .num.cash { color: #4ade80; }
7878 .abil-table .num.credit { color: #a78bfa; }
7979 .abil-table a { color: var(--col-accent-bright); text-decoration: none; }
8080 .abil-table .pill { font-size:9px; font-weight:700; padding:3px 8px; border-radius:6px; letter-spacing:1px; text-transform:uppercase; }
8181 .abil-table .pill.paid { background: rgba(34,197,94,0.18); color: #4ade80; }
8282 .abil-table .pill.open { background: rgba(245,158,11,0.18); color: #fbbf24; }
8383 .abil-table .pill.failed { background: rgba(239,68,68,0.20); color: #f87171; }
8484 .abil-table .pill.refunded { background: rgba(124,58,237,0.18); color: #a78bfa; }
8585 .abil-table .pill.void { background: rgba(100,116,139,0.20); color: #94a3b8; }
8686
8787 .abil-refund-form { display: inline; margin: 0; }
8888 .abil-refund-form button {
8989 background: none; border: none; padding: 0; font: inherit; cursor: pointer;
9090 color: #a78bfa; font-weight: 700; font-size: 12px;
9191 }
9292 .abil-refund-form button:hover { text-decoration: underline; }
9393</style>
9494
9595<div class="page-head">
9696 <div>
9797 <span class="page-eyebrow"><span class="dot"></span> Admin &middot; Billing</span>
9898 <h1 class="page-title">Billing console</h1>
9999 <p class="page-subtitle">Platform-wide subscription revenue, credit liability, and invoice activity. <strong>Cash revenue and credit consumption are reported separately</strong> -- credit applications never inflate the cash numbers.</p>
100100 </div>
101101 <div class="page-actions">
102102 [if:$not_tutorial_mode]<a href="/admin_billing.cgi?tutorial=1" class="btn btn-secondary"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>Sample Data View</a>[/if]
103103 [if:$tutorial_mode]<a href="/admin_billing.cgi" class="btn btn-secondary"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>Exit sample view</a>[/if]
104104 </div>
105105</div>
106106
107107[if:$tutorial_mode]
108108<div class="tutorial-banner">
109109 <div class="tutorial-banner-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg></div>
110110 <div class="tutorial-banner-body">
111111 <div class="tutorial-banner-title">Tutorial &middot; Sample data only</div>
112112 <div class="tutorial-banner-text">Every tile, plan distribution bar, invoice row and credit-holder entry below is example data. None of it reflects the real platform &mdash; it is here so staff can see how Admin &middot; Billing reads on a populated platform.</div>
113113 </div>
114114 <a href="/admin_billing.cgi" class="btn btn-secondary btn-sm">Exit sample view</a>
115115</div>
116116
117117<div class="tutorial-intro">
118118 <div class="tutorial-intro-eyebrow">A walkthrough of the Billing Console</div>
119119 <h2 class="tutorial-intro-title">This is the admin view of subscription revenue and the credit ledger.</h2>
120120 <p class="tutorial-intro-text">The tiles at the top split the platform's money into the categories that matter most: <strong>money received</strong> (real cash received this calendar month), <strong>credit consumed</strong> (services delivered against existing credit, no cash involved), and <strong>outstanding credit</strong> (the liability we owe users on their accounts). These tiles are intentionally separated so a credit application never inflates the cash line &mdash; the cash tile only counts <code>SUM(invoices.cash_paid_cents)</code>; the credit tile only counts <code>SUM(invoices.credit_applied_cents)</code>; the liability tile is <code>SUM(credit_ledger.amount_cents)</code> over the whole table.</p>
121121 <p class="tutorial-intro-text">Below the tiles you see the plan-tier distribution, the active / trialing / past-due / churn counts, the recent-invoices feed, and the accounts that currently hold credit. Any paid invoice in the table has a <strong>Refund as credit</strong> action &mdash; clicking it adds the cash amount back to the user's credit balance and marks the invoice as <em>Refunded as credit</em>. Money never moves back to the card; the user's next invoice consumes the credit first.</p>
122122 <p class="tutorial-intro-text tutorial-intro-foot">Click <a href="/admin_billing.cgi" class="text-brand fw-700">Exit sample view</a> to return to the live admin billing console.</p>
123123</div>
124124[/if]
125125
126126[if:$schema_missing]
127127<div class="an-banner" style="background:rgba(245,158,11,0.10);border-color:rgba(245,158,11,0.30)">
128128 <strong>Billing schema not yet installed.</strong> Run the latest <code>db_schema.sql</code> against the database to bring the billing tables online; once installed, this page populates with real subscription, credit, and invoice data.
129129</div>
130130[/if]
131131
132132[if:$schema_ready]
133133
134134[if:$is_super_admin]
135135<!-- Month/year selector -- scopes every tile below to that calendar
136136 month. Submits with native browser navigation so the URL is
137137 shareable and the back button works. Wrapped in the super-admin
138138 gate because the widgets it scopes are owner-only. -->
139139<div class="abil-period-bar" style="display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap;margin-bottom:14px">
140140 <div>
141141 <div class="text-xs text-dim" style="text-transform:uppercase;letter-spacing:0.06em;font-weight:600">Reporting period</div>
142142 <div class="fw-700" style="color:#fff;font-size:18px;line-height:1.2;margin-top:2px">$sel_period_label[if:$is_current_month] <span class="text-xs text-dim" style="font-weight:500;letter-spacing:0">(current month)</span>[/if]</div>
143143 </div>
144144 <form method="get" action="/admin_billing.cgi" style="display:flex;gap:8px;align-items:center;margin:0">
145145 <label class="text-xs text-dim" for="abil-month-select" style="font-weight:600">View month</label>
146146 <select id="abil-month-select" name="month" class="select" style="min-width:170px" onchange="this.form.submit()">
147147 [loop:@month_options]<option value="$loop1.ym"$loop1.selected_attr>$loop1.label</option>[/loop]
148148 </select>
149149 <noscript><button type="submit" class="btn btn-secondary btn-sm">Go</button></noscript>
150150 </form>
151151</div>
152152
153153<!-- Cash / credit / liability tiles -- all scoped to $sel_period_label.
154154 Platform-owner only. -->
155155[if:$has_daily_billing]<script>window.BILLING_DAILY_SERIES = $daily_billing_json;</script>[/if]
156156<div class="abil-banner-row">
157157 <div class="abil-tile tile-cash" data-kpi-detail='{"eyebrow":"Money received . selected month (platform)","description":"Real cash received from paid invoices during the selected calendar month. Credit applications are not counted -- this is the true cash line.","methodology":"<code>SUM(invoices.cash_paid_cents) WHERE status=&#39;paid&#39; AND paid_at WITHIN selected_month</code>. Each invoice carries cash_paid_cents and credit_applied_cents separately; only the cash column is summed here.","interpretation":"Primary platform health metric. Use the month selector above to compare against the same month last year or against the prior month to read growth trends.","seriesGlobal":"BILLING_DAILY_SERIES","seriesKey":"cash","chartColor":"#22c55e","chartLabel":"Cash ($)","chartTitle":"Daily cash received in selected month"}'>
158158 <div class="lbl">Money received</div>
159159 <div class="val">$cash_mtd</div>
160160 <div class="sub">Actual cash received in $sel_period_label. Excludes credit applications.</div>
161161 </div>
162162 <div class="abil-tile tile-credit" data-kpi-detail='{"eyebrow":"Credit consumed . selected month (platform)","description":"Total dollar amount of services delivered against user credit balances during the selected calendar month. No cash changes hands when credit is applied -- this metric tracks how fast we are burning down our liability to users.","methodology":"<code>SUM(invoices.credit_applied_cents) WHERE paid_at WITHIN selected_month</code>. Each invoice can be partially cash and partially credit; the cash side feeds the Cash Revenue tile and the credit side feeds this one.","interpretation":"High credit consumption with low cash revenue means the platform is delivering value users have already paid for. That is fine in moderation but means we are reducing the Outstanding Credit liability without earning new cash.","seriesGlobal":"BILLING_DAILY_SERIES","seriesKey":"credit","chartColor":"#a78bfa","chartLabel":"Credit ($)","chartTitle":"Daily credit consumed in selected month"}'>
163163 <div class="lbl">Credit consumed</div>
164164 <div class="val">$credit_mtd</div>
165165 <div class="sub">Services delivered against credit in $sel_period_label. No cash involved.</div>
166166 </div>
167167 <div class="abil-tile tile-debt" data-kpi-detail='{"eyebrow":"Outstanding credit . end of selected month (platform)","description":"Total dollar value of credit balances held across every user account. This is a liability -- the platform owes users this much in future service or refunds. For past months we show the balance as it stood at month-end; for the current month we show the live balance.","methodology":"<code>SUM(credit_ledger.amount_cents)</code>. Live for the current month, snapshotted at month-end for past months. Positive entries are credits granted; negative entries are credits consumed when invoices apply them.","interpretation":"Watch the trend month-over-month. Rising outstanding credit with steady cash revenue means we are granting refunds faster than users are spending them. Falling outstanding credit means users are converting their balances into shipped services.","seriesGlobal":"BILLING_DAILY_SERIES","seriesKey":"credit","chartColor":"#fbbf24","chartLabel":"Credit consumed ($)","chartTitle":"Daily credit consumption (drives liability changes)"}'>
168168 <div class="lbl">Outstanding credit[if:$not_current_month] (month-end)[/if]</div>
169169 <div class="val">$outstanding_credit</div>
170170 <div class="sub">[if:$is_current_month]Liability owed to users right now.[/if][if:$not_current_month]Liability as of end of $sel_period_label.[/if] Will reduce future cash revenue as it is consumed.</div>
171171 </div>
172172 <div class="abil-tile tile-cash" style="border-left:3px solid var(--col-accent)" data-kpi-detail='{"eyebrow":"Projected month-end cash (platform)","description":"Best estimate of where cash revenue will land at month-end. For the current month: MTD pace extrapolated linearly + scheduled subscription renewals. For past months: the actual cash that landed.","methodology":"Current month: <code>(cash_mtd / days_elapsed) * days_in_month + scheduled_renewals_in_remaining_days</code>. Past month: equals Money Received for that month.","interpretation":"For the current month this is the number that drives mid-month decisions. For past months it is the closed total -- compare across months in the selector to read growth.","seriesGlobal":"BILLING_DAILY_SERIES","seriesKey":"cash","chartColor":"#3b82f6","chartLabel":"Cash ($)","chartTitle":"Daily cash revenue (the pace driving this projection)"}'>
173173 <div class="lbl">[if:$is_current_month]Projected (month-end)[/if][if:$not_current_month]Total received (month-end)[/if]</div>
174174 <div class="val">$projected_month</div>
175175 <div class="sub">
176176 [if:$is_current_month]
177177 $projection_days_elap of $projection_days_in_m days in. Pace alone: $projected_pace_only.
178178 Scheduled renewals in the remaining $projection_days_left day(s): $projected_subs_due.
179179 [/if]
180180 [if:$not_current_month]
181181 Final cash total for $sel_period_label across $projection_days_in_m days.
182182 [/if]
183183 </div>
184184 </div>
185185</div>
186186
187187<!-- Signups (free vs paid) -- by day and by month -->
188188[if:$has_signups]
189189<script>
190190 window.SIGNUPS_DAILY = $signups_daily_json;
191191 window.SIGNUPS_MONTHLY = $signups_monthly_json;
192192</script>
193193<div class="module mt-3" style="margin-bottom:18px">
194194 <div class="module-head" style="flex-wrap:wrap;gap:10px">
195195 <div class="left">
196196 <div class="module-icon" style="background:rgba(34,197,94,0.15);color:#4ade80"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg></div>
197197 <div>
198198 <div class="module-title">Signups (free + paid)</div>
199199 <div class="module-sub"><span class="font-mono">$signups_total_30d</span> total in last 30 days &middot; <span class="font-mono" style="color:#4ade80">$signups_paid_30d paid</span> &middot; <span class="font-mono" style="color:#94a3b8">$signups_free_30d free</span></div>
200200 </div>
201201 </div>
202202 <div class="signups-toggle" style="display:flex;gap:4px;background:rgba(255,255,255,0.04);padding:4px;border-radius:8px;border:1px solid var(--col-border)">
203203 <button type="button" class="btn btn-sm signups-tab is-active" data-signups-view="daily" style="background:rgba(59,130,246,0.18);color:#fff">Day (30d)</button>
204204 <button type="button" class="btn btn-sm signups-tab" data-signups-view="monthly" style="background:transparent;color:var(--col-text-dim)">Month (12mo)</button>
205205 </div>
206206 </div>
207207 <div class="module-body">
208208 <div id="signups-chart-wrap" style="position:relative">
209209 <canvas id="signups-chart" style="display:block;width:100%;height:240px;cursor:pointer"></canvas>
210210 <!-- Floating tooltip; positioned by JS on mousemove. position:fixed
211211 anchors it to the viewport so it escapes every parent stacking
212212 context (the modules below were layering on top at z-index:auto).
213213 Pointer-events off so it never blocks the canvas hit-testing. -->
214214 <div id="signups-tooltip" style="position:fixed;display:none;pointer-events:none;background:rgba(8,12,24,0.96);border:1px solid var(--col-border);border-radius:8px;padding:10px 12px;font-size:12px;color:var(--col-text-2);box-shadow:0 12px 32px rgba(0,0,0,0.55);min-width:170px;z-index:900"></div>
215215 <div id="signups-legend" style="display:flex;gap:18px;align-items:center;margin-top:10px;font-size:12px;color:var(--col-text-dim);flex-wrap:wrap">
216216 <span style="display:inline-flex;align-items:center;gap:6px"><span style="display:inline-block;width:12px;height:12px;background:#4ade80;border-radius:2px"></span>Paid (starter / pro / studio)</span>
217217 <span style="display:inline-flex;align-items:center;gap:6px"><span style="display:inline-block;width:12px;height:12px;background:#94a3b8;border-radius:2px"></span>Free</span>
218218 <span style="margin-left:auto;color:var(--col-text-dim);font-style:italic">Hover a bar for stats . click for the full breakdown</span>
219219 </div>
220220 </div>
221221 </div>
222222</div>
223223
224224<!-- Click-modal overlay. Hidden by default; JS swaps display=flex and
225225 populates #signups-modal-body with the per-tier breakdown for the
226226 bar that was clicked. Click outside the inner card OR the X button
227227 to dismiss. -->
228228<div id="signups-modal" style="display:none;position:fixed;inset:0;background:rgba(2,4,12,0.72);backdrop-filter:blur(4px);z-index:1000;align-items:center;justify-content:center;padding:24px">
229229 <div id="signups-modal-card" style="background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:14px;max-width:520px;width:100%;max-height:90vh;overflow:auto;box-shadow:0 24px 60px rgba(0,0,0,0.6)">
230230 <div style="display:flex;align-items:flex-start;justify-content:space-between;padding:18px 20px;border-bottom:1px solid var(--col-border);gap:12px">
231231 <div>
232232 <div class="text-xs text-dim" style="text-transform:uppercase;letter-spacing:0.08em;font-weight:600">Signups</div>
233233 <div id="signups-modal-title" class="fw-700" style="color:#fff;font-size:20px;line-height:1.25;margin-top:2px"></div>
234234 </div>
235235 <button type="button" id="signups-modal-close" aria-label="Close" style="background:none;border:0;color:var(--col-text-dim);cursor:pointer;padding:4px;line-height:0">
236236 <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
237237 </button>
238238 </div>
239239 <div id="signups-modal-body" style="padding:20px"></div>
240240 </div>
241241</div>
242242<script>
243243(function () {
244244 var canvas = document.getElementById('signups-chart');
245245 if (!canvas || !window.SIGNUPS_DAILY || !window.SIGNUPS_MONTHLY) return;
246246 var ctx = canvas.getContext('2d');
247247 var view = 'daily';
248248
249249 function rows() { return view === 'daily' ? window.SIGNUPS_DAILY : window.SIGNUPS_MONTHLY; }
250250 function xKey() { return view === 'daily' ? 'date' : 'month'; }
251251 function shortLabel(s) {
252252 if (view === 'monthly') {
253253 var p = s.split('-'); var mo = parseInt(p[1], 10);
254254 var names = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
255255 return names[mo - 1] + ' ' + p[0].slice(2);
256256 }
257257 var p = s.split('-');
258258 return p[1] + '/' + p[2];
259259 }
260260
261261 function draw() {
262262 var data = rows();
263263 var dpr = window.devicePixelRatio || 1;
264264 var w = canvas.clientWidth || canvas.offsetWidth || 600;
265265 var h = 240;
266266 canvas.width = Math.floor(w * dpr);
267267 canvas.height = Math.floor(h * dpr);
268268 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
269269 ctx.clearRect(0, 0, w, h);
270270
271271 var padL = 36, padR = 12, padT = 14, padB = 28;
272272 var plotW = w - padL - padR;
273273 var plotH = h - padT - padB;
274274 var maxTotal = 1;
275275 for (var i = 0; i < data.length; i++) {
276276 if (data[i].total > maxTotal) maxTotal = data[i].total;
277277 }
278278 // Round max up to next nice number for the y-axis.
279279 var step = Math.pow(10, Math.floor(Math.log10(maxTotal)));
280280 var yMax = Math.ceil(maxTotal / step) * step;
281281 if (yMax < 4) yMax = 4;
282282
283283 // Gridlines + y labels.
284284 ctx.strokeStyle = 'rgba(255,255,255,0.06)';
285285 ctx.fillStyle = 'rgba(255,255,255,0.45)';
286286 ctx.font = '11px ui-sans-serif, system-ui, sans-serif';
287287 ctx.textBaseline = 'middle';
288288 for (var g = 0; g <= 4; g++) {
289289 var y = padT + plotH * (1 - g / 4);
290290 ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(padL + plotW, y); ctx.stroke();
291291 ctx.textAlign = 'right';
292292 ctx.fillText(String(Math.round(yMax * g / 4)), padL - 6, y);
293293 }
294294
295295 // Bars -- stacked paid (bottom) + free (top).
296296 var n = data.length;
297297 var bw = Math.max(2, Math.floor(plotW / n) - 2);
298298 var step_x = plotW / n;
299299 for (var j = 0; j < n; j++) {
300300 var x = padL + j * step_x + (step_x - bw) / 2;
301301 var paid = data[j].paid;
302302 var free = data[j].free;
303303 var paidH = (paid / yMax) * plotH;
304304 var freeH = (free / yMax) * plotH;
305305 var yPaid = padT + plotH - paidH;
306306 var yFree = yPaid - freeH;
307307 if (paidH > 0) {
308308 ctx.fillStyle = '#4ade80';
309309 ctx.fillRect(x, yPaid, bw, paidH);
310310 }
311311 if (freeH > 0) {
312312 ctx.fillStyle = '#94a3b8';
313313 ctx.fillRect(x, yFree, bw, freeH);
314314 }
315315 }
316316
317317 // X labels -- show roughly 6 evenly.
318318 ctx.fillStyle = 'rgba(255,255,255,0.55)';
319319 ctx.textAlign = 'center';
320320 ctx.textBaseline = 'top';
321321 var labelEvery = Math.max(1, Math.ceil(n / 6));
322322 for (var k = 0; k < n; k++) {
323323 if (k % labelEvery !== 0 && k !== n - 1) continue;
324324 var lx = padL + k * step_x + step_x / 2;
325325 ctx.fillText(shortLabel(data[k][xKey()]), lx, padT + plotH + 6);
326326 }
327327 }
328328
329329 // Map a pointer event to a chart-bar index, or -1 outside the plot.
330330 function indexFromEvent(e) {
331331 var rect = canvas.getBoundingClientRect();
332332 var x = e.clientX - rect.left;
333333 var w = canvas.clientWidth;
334334 var padL = 36, padR = 12;
335335 var plotW = w - padL - padR;
336336 var data = rows();
337337 var n = data.length;
338338 if (x < padL || x > padL + plotW || n === 0) return -1;
339339 var idx = Math.floor((x - padL) / (plotW / n));
340340 if (idx < 0) idx = 0; else if (idx >= n) idx = n - 1;
341341 return idx;
342342 }
343343
344344 // Build the long-form label for a row -- used by the tooltip header
345345 // and the modal title. "May 12, 2026" for daily, "May 2026" for monthly.
346346 var MONTH_NAMES = ['January','February','March','April','May','June','July','August','September','October','November','December'];
347347 function longLabel(row) {
348348 if (view === 'monthly') {
349349 var p = row.month.split('-');
350350 return MONTH_NAMES[parseInt(p[1], 10) - 1] + ' ' + p[0];
351351 }
352352 var p = row.date.split('-');
353353 return MONTH_NAMES[parseInt(p[1], 10) - 1] + ' ' + parseInt(p[2], 10) + ', ' + p[0];
354354 }
355355
356356 // Floating tooltip. Mirrors the bar's row counts; positioned next
357357 // to the cursor with a flip when it would clip the viewport edge.
358358 var tooltip = document.getElementById('signups-tooltip');
359359 function showTooltip(e, row) {
360360 var paidParts = [];
361361 if (row.starter) paidParts.push(row.starter + ' starter');
362362 if (row.pro) paidParts.push(row.pro + ' pro');
363363 if (row.studio) paidParts.push(row.studio + ' studio');
364364 var paidSubLine = paidParts.length
365365 ? '<div style="font-size:11px;margin:-2px 0 4px;text-align:right;color:var(--col-text-dim)">(' + paidParts.join(', ') + ')</div>'
366366 : '';
367367 tooltip.innerHTML =
368368 '<div style="color:#fff;font-weight:700;margin-bottom:6px">' + longLabel(row) + '</div>' +
369369 '<div style="display:flex;justify-content:space-between;gap:14px"><span>Total</span><span style="color:#fff;font-family:var(--font-mono)">' + row.total + '</span></div>' +
370370 '<div style="display:flex;justify-content:space-between;gap:14px;color:#4ade80"><span>Paid</span><span style="font-family:var(--font-mono)">' + row.paid + '</span></div>' +
371371 paidSubLine +
372372 '<div style="display:flex;justify-content:space-between;gap:14px;color:#94a3b8"><span>Free</span><span style="font-family:var(--font-mono)">' + row.free + '</span></div>';
373373 tooltip.style.display = 'block';
374374 // Tooltip is position:fixed -- coords are viewport-relative.
375375 // Offset slightly from the cursor, flip to the left if we'd
376376 // overflow the right edge, clamp the bottom edge too.
377377 var tw = tooltip.offsetWidth || 200;
378378 var th = tooltip.offsetHeight || 100;
379379 var tx = e.clientX + 14;
380380 var ty = e.clientY + 14;
381381 if (tx + tw + 8 > window.innerWidth) tx = e.clientX - tw - 14;
382382 if (ty + th + 8 > window.innerHeight) ty = e.clientY - th - 14;
383383 if (tx < 4) tx = 4;
384384 if (ty < 4) ty = 4;
385385 tooltip.style.left = tx + 'px';
386386 tooltip.style.top = ty + 'px';
387387 }
388388 function hideTooltip() { tooltip.style.display = 'none'; }
389389
390390 canvas.addEventListener('mousemove', function (e) {
391391 var idx = indexFromEvent(e);
392392 if (idx < 0) { hideTooltip(); return; }
393393 showTooltip(e, rows()[idx]);
394394 });
395395 canvas.addEventListener('mouseleave', hideTooltip);
396396
397397 // Click -> modal. Reads from the same preloaded data so there's no
398398 // round-trip; per-tier counts live in every row.
399399 var modal = document.getElementById('signups-modal');
400400 var modalTitle = document.getElementById('signups-modal-title');
401401 var modalBody = document.getElementById('signups-modal-body');
402402 var modalCard = document.getElementById('signups-modal-card');
403403 function openModal(row) {
404404 modalTitle.textContent = longLabel(row);
405405 var pct = function (n) { return row.total ? Math.round((n / row.total) * 100) : 0; };
406406 var tierRow = function (label, n, color) {
407407 var p = pct(n);
408408 return '' +
409409 '<div style="display:flex;align-items:center;gap:12px;margin:0 0 10px">' +
410410 '<div style="width:64px;color:var(--col-text-2);font-size:13px">' + label + '</div>' +
411411 '<div style="flex:1;height:10px;background:rgba(255,255,255,0.06);border-radius:5px;overflow:hidden">' +
412412 '<div style="width:' + p + '%;height:100%;background:' + color + ';transition:width 0.25s ease"></div>' +
413413 '</div>' +
414414 '<div style="min-width:78px;text-align:right;font-family:var(--font-mono);color:#fff">' + n + ' <span style="color:var(--col-text-dim);font-size:11px">(' + p + '%)</span></div>' +
415415 '</div>';
416416 };
417417 modalBody.innerHTML =
418418 '<div style="display:flex;align-items:baseline;gap:10px;margin-bottom:18px">' +
419419 '<div style="font-size:34px;font-weight:700;color:#fff;line-height:1">' + row.total + '</div>' +
420420 '<div class="text-xs text-dim" style="text-transform:uppercase;letter-spacing:0.08em">Total signups</div>' +
421421 '</div>' +
422422 '<div style="display:flex;gap:14px;margin-bottom:18px">' +
423423 '<div style="flex:1;padding:12px 14px;background:rgba(74,222,128,0.08);border:1px solid rgba(74,222,128,0.2);border-radius:10px">' +
424424 '<div class="text-xs text-dim" style="text-transform:uppercase;letter-spacing:0.06em;font-weight:600;color:#4ade80">Paid</div>' +
425425 '<div style="font-size:22px;font-weight:700;color:#fff;margin-top:2px">' + row.paid + '</div>' +
426426 '</div>' +
427427 '<div style="flex:1;padding:12px 14px;background:rgba(148,163,184,0.06);border:1px solid rgba(148,163,184,0.18);border-radius:10px">' +
428428 '<div class="text-xs text-dim" style="text-transform:uppercase;letter-spacing:0.06em;font-weight:600">Free</div>' +
429429 '<div style="font-size:22px;font-weight:700;color:#fff;margin-top:2px">' + row.free + '</div>' +
430430 '</div>' +
431431 '</div>' +
432432 '<div class="text-xs text-dim" style="text-transform:uppercase;letter-spacing:0.08em;font-weight:600;margin:0 0 10px">Breakdown by tier</div>' +
433433 tierRow('Free', row.free, '#94a3b8') +
434434 tierRow('Starter', row.starter, '#60a5fa') +
435435 tierRow('Pro', row.pro, '#a78bfa') +
436436 tierRow('Studio', row.studio, '#fbbf24');
437437 modal.style.display = 'flex';
438438 }
439439 function closeModal() { modal.style.display = 'none'; }
440440
441441 canvas.addEventListener('click', function (e) {
442442 var idx = indexFromEvent(e);
443443 if (idx < 0) return;
444444 openModal(rows()[idx]);
445445 });
446446 document.getElementById('signups-modal-close').addEventListener('click', closeModal);
447447 modal.addEventListener('click', function (e) {
448448 if (e.target === modal) closeModal();
449449 });
450450 document.addEventListener('keydown', function (e) {
451451 if (e.key === 'Escape' && modal.style.display === 'flex') closeModal();
452452 });
453453
454454 // Tab toggle.
455455 var tabs = document.querySelectorAll('[data-signups-view]');
456456 tabs.forEach(function (btn) {
457457 btn.addEventListener('click', function () {
458458 view = btn.getAttribute('data-signups-view');
459459 tabs.forEach(function (b) {
460460 var on = b === btn;
461461 b.classList.toggle('is-active', on);
462462 b.style.background = on ? 'rgba(59,130,246,0.18)' : 'transparent';
463463 b.style.color = on ? '#fff' : 'var(--col-text-dim)';
464464 });
465465 draw();
466466 });
467467 });
468468
469469 window.addEventListener('resize', draw);
470470 draw();
471471})();
472472</script>
473473[/if]
474474[if:!$has_signups]
475475<div class="module mt-3">
476476 <div class="module-head">
477477 <div class="left">
478478 <div class="module-icon" style="background:rgba(34,197,94,0.15);color:#4ade80"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg></div>
479479 <div>
480480 <div class="module-title">Signups (free + paid)</div>
481481 <div class="module-sub">No signups yet &mdash; chart appears once the first user account is created.</div>
482482 </div>
483483 </div>
484484 </div>
485485</div>
486486[/if]
487487
488488<!-- Plan breakdown + subscription status counts -->
489489<div class="abil-twocol">
490490 <div class="module">
491491 <div class="module-head">
492492 <div class="left">
493493 <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="9" x2="9" y2="21"/></svg></div>
494494 <div>
495495 <div class="module-title">Plan distribution</div>
496496 <div class="module-sub">Active + trialing + past_due across all tiers</div>
497497 </div>
498498 </div>
499499 </div>
500500 <div class="module-body">
501501 [if:$has_plan_breakdown]
502502 <div class="abil-plan-bar">
503503 [loop:@plan_breakdown]
504504 <div class="abil-plan-row">
505505 <div class="abil-plan-name">$loop1.tier_label</div>
506506 <div class="abil-plan-track">
507507 <div class="abil-plan-fill" style="width:$loop1.pct%;background:$loop1.color"></div>
508508 </div>
509509 <div class="abil-plan-meta">$loop1.count &middot; $loop1.pct%</div>
510510 </div>
511511 [/loop]
512512 </div>
513513 [/if]
514514 [if:!$has_plan_breakdown]
515515 <div class="text-xs text-dim" style="text-align:center;padding:18px 12px">No active subscriptions yet.</div>
516516 [/if]
517517 </div>
518518 </div>
519519
520520 <div class="module">
521521 <div class="module-head">
522522 <div class="left">
523523 <div class="module-icon" style="background:rgba(59,130,246,0.15);color:#60a5fa"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18"/><path d="m7 14 4-4 4 4 5-5"/></svg></div>
524524 <div>
525525 <div class="module-title">Recurring revenue</div>
526526 <div class="module-sub">MRR / ARR + subscription status</div>
527527 </div>
528528 </div>
529529 </div>
530530 <div class="module-body" style="display:flex;flex-direction:column;gap:10px">
531531 <div class="flex justify-between" style="padding:8px 0">
532532 <span class="text-xs text-dim" style="letter-spacing:1.5px;text-transform:uppercase">MRR</span>
533533 <span class="font-mono fw-700" style="color:#fff">$mrr_display</span>
534534 </div>
535535 <div class="flex justify-between" style="padding:8px 0;border-top:1px dashed var(--col-border)">
536536 <span class="text-xs text-dim" style="letter-spacing:1.5px;text-transform:uppercase">ARR</span>
537537 <span class="font-mono fw-700" style="color:#fff">$arr_display</span>
538538 </div>
539539 <div class="flex justify-between" style="padding:8px 0;border-top:1px dashed var(--col-border)">
540540 <span class="text-xs text-dim" style="letter-spacing:1.5px;text-transform:uppercase">Active</span>
541541 <span class="font-mono fw-700" style="color:#4ade80">$active_subs</span>
542542 </div>
543543 <div class="flex justify-between" style="padding:8px 0">
544544 <span class="text-xs text-dim" style="letter-spacing:1.5px;text-transform:uppercase">Trialing</span>
545545 <span class="font-mono fw-700" style="color:#a78bfa">$trialing_subs</span>
546546 </div>
547547 <div class="flex justify-between" style="padding:8px 0">
548548 <span class="text-xs text-dim" style="letter-spacing:1.5px;text-transform:uppercase">Past due</span>
549549 <span class="font-mono fw-700" style="color:#fbbf24">$past_due_subs</span>
550550 </div>
551551 <div class="flex justify-between" style="padding:8px 0;border-top:1px dashed var(--col-border)">
552552 <span class="text-xs text-dim" style="letter-spacing:1.5px;text-transform:uppercase">Canceled (30d)</span>
553553 <span class="font-mono fw-700" style="color:#f87171">$canceled_subs_30d</span>
554554 </div>
555555 </div>
556556 </div>
557557</div>
558558[/if]
559559<!-- end super-admin-only platform overview -->
560560
561561[if:$not_super_admin]
562562<!-- Manager / Editor banner: explains why the revenue dashboards are
563563 hidden from this role. The user-support panels (refunds + credit
564564 grants) below still work as before. -->
565565<div class="banner mb-3" style="padding:12px 14px;border-radius:10px;background:rgba(99,102,241,0.08);border:1px solid rgba(99,102,241,0.25);display:flex;gap:10px;align-items:flex-start">
566566 <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#a5b4fc" stroke-width="2" style="flex:0 0 auto;margin-top:1px"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
567567 <div style="flex:1">
568568 <div class="fw-700" style="color:#fff;font-size:13px">User support view</div>
569569 <div class="text-xs text-dim" style="margin-top:2px">You can refund invoices and grant credit from the panels below. Platform-wide revenue, MRR, and signup dashboards are visible only to the platform owner.</div>
570570 </div>
571571</div>
572572[/if]
573573
574574<!-- Recent invoices across all users -->
575575<h2 style="font-size:18px;margin:18px 0 14px;color:var(--col-text)">Recent invoices</h2>
576576[if:$has_recent_invoices]
577577<table class="abil-table">
578578 <thead>
579579 <tr>
580580 <th>Invoice</th>
581581 <th>Customer</th>
582582 <th class="num">Due</th>
583583 <th class="num">Credit</th>
584584 <th class="num">Cash</th>
585585 <th>Status</th>
586586 <th>Action</th>
587587 </tr>
588588 </thead>
589589 <tbody>
590590 [loop:@recent_invoices]
591591 <tr>
592592 <td>
593593 <div style="color:var(--col-text);font-weight:600">$loop1.invoice_number</div>
594594 <div class="text-xs text-dim"><span class="ts" data-ts="$loop1.issued_epoch" data-fmt="date">$loop1.issued_at</span></div>
595595 </td>
596596 <td>
597597 <a href="/admin_user.cgi?u=$loop1.user_id">$loop1.user_label</a>
598598 <div class="text-xs text-dim">$loop1.user_email</div>
599599 </td>
600600 <td class="num">$loop1.amount_due_display</td>
601601 <td class="num credit">$loop1.credit_applied_display</td>
602602 <td class="num cash">$loop1.cash_paid_display</td>
603603 <td>
604604 [if:$loop1.is_paid]<span class="pill paid">Paid</span>[/if]
605605 [if:$loop1.is_open]<span class="pill open">Open</span>[/if]
606606 [if:$loop1.is_failed]<span class="pill failed">Failed</span>[/if]
607607 [if:$loop1.is_refunded]<span class="pill refunded">Refunded as credit</span>[/if]
608608 [if:$loop1.is_void]<span class="pill void">Void</span>[/if]
609609 </td>
610610 <td>
611611 [if:$loop1.can_refund]
612612 <form class="abil-refund-form" method="POST" action="/admin_billing_action.cgi"
613613 data-confirm-title="Refund $loop1.cash_paid_display as account credit?"
614614 data-confirm-message="Money does NOT leave Stripe. It becomes credit on $loop1.user_label and applies to their next invoice."
615615 data-confirm-label="Refund as credit"
616616 data-confirm-style="warning">
617617 <input type="hidden" name="act" value="refund_as_credit">
618618 <input type="hidden" name="invoice_id" value="$loop1.id">
619619 <button type="submit">Refund as credit</button>
620620 </form>
621621 [/if]
622622 [if:!$loop1.can_refund]<span class="text-xs text-dim">-</span>[/if]
623623 </td>
624624 </tr>
625625 [/loop]
626626 </tbody>
627627</table>
628628[/if]
629629[if:!$has_recent_invoices]
630630<div style="text-align:center;padding:36px 18px;background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:12px">
631631 <div class="text-sm fw-600" style="color:#fff;margin-bottom:6px">No invoices yet</div>
632632 <div class="text-xs text-dim">Invoices appear here as soon as the billing worker generates the first one.</div>
633633</div>
634634[/if]
635635
636636<!-- Credit holders -->
637637<h2 style="font-size:18px;margin:24px 0 14px;color:var(--col-text)">Accounts with credit on file</h2>
638638[if:$has_credit_holders]
639639<table class="abil-table">
640640 <thead>
641641 <tr>
642642 <th>User</th>
643643 <th>Email</th>
644644 <th class="num">Credit balance</th>
645645 <th>Action</th>
646646 </tr>
647647 </thead>
648648 <tbody>
649649 [loop:@credit_holders]
650650 <tr>
651651 <td><a href="/admin_user.cgi?u=$loop1.user_id">$loop1.user_label</a></td>
652652 <td>$loop1.user_email</td>
653653 <td class="num credit">$loop1.balance_display</td>
654654 <td>
655655 <form class="abil-refund-form abil-grant-form" method="POST" action="/admin_billing_action.cgi"
656656 data-grant-user-label="$loop1.user_label">
657657 <input type="hidden" name="act" value="grant_credit">
658658 <input type="hidden" name="user_id" value="$loop1.user_id">
659659 <input type="hidden" name="amount_cents" value="">
660660 <input type="hidden" name="note" value="">
661661 <button type="button" class="js-grant-credit">Grant credit</button>
662662 </form>
663663 </td>
664664 </tr>
665665 [/loop]
666666 </tbody>
667667</table>
668668[/if]
669669[if:!$has_credit_holders]
670670<div style="text-align:center;padding:36px 18px;background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:12px">
671671 <div class="text-sm fw-600" style="color:#fff;margin-bottom:6px">No accounts hold credit right now</div>
672672 <div class="text-xs text-dim">Accounts appear here as soon as the first credit_ledger row lands.</div>
673673</div>
674674[/if]
675675[/if]
676676
677677<script>
678678// Credit-grant flow: amount prompt -> note prompt -> confirm -> submit.
679679// Single delegated handler so the credit-holders loop body stays
680680// thin (just markup; no per-row script).
681681document.addEventListener('click', function (e) {
682682 var btn = e.target.closest('.js-grant-credit');
683683 if (!btn) return;
684684 e.preventDefault();
685685 var form = btn.closest('.abil-grant-form');
686686 if (!form) return;
687687 var label = form.getAttribute('data-grant-user-label') || 'this user';
688688 appPrompt({
689689 title: 'Grant credit to ' + label,
690690 message: 'Enter the amount in cents. Example: 500 for \$5.00.',
691691 placeholder: '500',
692692 input_type: 'number',
693693 label: 'Next',
694694 required: true,
695695 validate: function (v) {
696696 if (!/^\d+\$/.test(v)) return 'Whole-cent integer required (no decimals, no dollar sign).';
697697 if (parseInt(v, 10) <= 0) return 'Must be greater than zero.';
698698 return '';
699699 }
700700 }).then(function (amt) {
701701 if (amt === null) return;
702702 appPrompt({
703703 title: 'Note for ' + label,
704704 message: 'Shown to the user on their credit history. Keep it short and clear.',
705705 placeholder: 'Goodwill credit',
706706 default_value: 'Goodwill credit',
707707 label: 'Next',
708708 required: true
709709 }).then(function (note) {
710710 if (note === null) return;
711711 var dollars = (parseInt(amt, 10) / 100).toFixed(2);
712712 appConfirm({
713713 title: 'Grant \$' + dollars + ' credit to ' + label + '?',
714714 message: 'Note: "' + note + '". This appends a row to the credit ledger immediately and shows up on their next invoice.',
715715 confirm_label: 'Grant credit',
716716 confirm_style: 'success'
717717 }).then(function (ok) {
718718 if (!ok) return;
719719 form.querySelector('input[name="amount_cents"]').value = amt;
720720 form.querySelector('input[name="note"]').value = note;
721721 form.submit();
722722 });
723723 });
724724 });
725725});
726726</script>
727727
728728<div class="adm-foot-spacer" data-end="admin-billing">&nbsp;</div>