CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

fix(billing+dashboard): honest peak-day figure; honest verify-email banner (refs #211) #5460

Merged⚡ AI-generatedXSccantynz wants to mergesweep/billing-bannermainopened 24d ago
3 changed files+107−5
Modifiedsrc/__tests__/ai-cost-tracker.test.ts+78−0View fileUnifiedSplit
247247 });
248248});
249249
250// ─── 4b. Display invariants — one rounding policy ───────────────────────
251//
252// Audit defect (#211): /billing/usage showed "Peak day $0.01" next to
253// "30-day total $0.00" because the sparkline's chart-scale clamp
254// (Math.max(1, ...)) leaked into the displayed peak. These tests pin the
255// honesty invariant: every displayed figure derives from the same summed
256// integer-cents series, so total >= max(daily) always.
257
258import { buildTrendSparkline } from "../routes/billing-usage";
259
260describe("buildTrendSparkline — displayed figures share one rounding policy", () => {
261 it("all-zero window: peak is $0.00, never $0.01", () => {
262 const t = buildTrendSparkline([], 30);
263 expect(t.max).toBe(0);
264 expect(t.total).toBe(0);
265 expect(formatCents(t.max)).toBe("$0.00");
266 expect(formatCents(t.total)).toBe("$0.00");
267 });
268
269 it("invariant: total >= max(daily) for any series", () => {
270 const today = toUtcDayKey(new Date());
271 const yesterday = toUtcDayKey(new Date(Date.now() - 24 * 3600 * 1000));
272 const cases: Array<Array<{ day: string; cents: number }>> = [
273 [],
274 [{ day: today, cents: 1 }],
275 [
276 { day: today, cents: 3 },
277 { day: yesterday, cents: 7 },
278 ],
279 [{ day: "1999-01-01", cents: 999 }], // outside window → contributes 0
280 ];
281 for (const byDay of cases) {
282 const t = buildTrendSparkline(byDay, 30);
283 expect(t.total).toBeGreaterThanOrEqual(t.max);
284 expect(t.max).toBeGreaterThanOrEqual(0);
285 }
286 });
287
288 it("single spend day: peak equals total, both from the same summed cents", () => {
289 const today = toUtcDayKey(new Date());
290 const t = buildTrendSparkline([{ day: today, cents: 42 }], 30);
291 expect(t.max).toBe(42);
292 expect(t.total).toBe(42);
293 });
294
295 it("aggregateEvents: totalCents >= max(byDay cents) — the upstream series", () => {
296 const rows = [
297 {
298 occurredAt: new Date("2026-05-01T10:00:00Z"),
299 model: "claude-sonnet-4-20250514",
300 category: "ai_review",
301 repositoryId: null,
302 agentSessionId: null,
303 centsEstimate: 5,
304 inputTokens: 100,
305 outputTokens: 10,
306 },
307 {
308 occurredAt: new Date("2026-05-02T10:00:00Z"),
309 model: "claude-sonnet-4-20250514",
310 category: "chat",
311 repositoryId: null,
312 agentSessionId: null,
313 centsEstimate: 2,
314 inputTokens: 50,
315 outputTokens: 5,
316 },
317 ];
318 const s = aggregateEvents(rows);
319 const maxDaily = Math.max(0, ...s.byDay.map((d) => d.cents));
320 expect(s.totalCents).toBeGreaterThanOrEqual(maxDaily);
321 // Empty set: both zero.
322 const empty = aggregateEvents([]);
323 expect(empty.totalCents).toBe(0);
324 expect(Math.max(0, ...empty.byDay.map((d) => d.cents))).toBe(0);
325 });
326});
327
250328// ─── 5. DB-backed recordAiCost — gated on HAS_DB ────────────────────────
251329
252330describe.skipIf(!HAS_DB)("recordAiCost — DB-backed", () => {
Modifiedsrc/routes/billing-usage.tsx+17−3View fileUnifiedSplit
224224 .cost-sublinks a.is-current { color: var(--text-strong); border-color: color-mix(in srgb, var(--accent) 45%, transparent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
225225`;
226226
227/** Build the inline-SVG sparkline for the last N days. Pure function. */
227/** Build the inline-SVG sparkline for the last N days. Pure function.
228 *
229 * One rounding policy: `centsEstimate` is already an integer per event, the
230 * day buckets sum those integers, and both figures returned here derive
231 * from the SAME series — `max` is the true peak day (0 when there was no
232 * spend) and `total` is the sum. Because every daily value is >= 0, the
233 * invariant `total >= max` always holds, so the dashboard can never show a
234 * nonzero peak next to a zero 30-day total. The chart's Y-axis denominator
235 * is clamped to >= 1 internally (divide-by-zero guard) but is NEVER
236 * surfaced as a dollar figure — that clamp leaking into the "Peak day"
237 * label was exactly the audit defect. */
228238export function buildTrendSparkline(
229239 byDayCents: Array<{ day: string; cents: number }>,
230240 days = 30
239249 const key = toUtcDayKey(d);
240250 series.push({ day: key, cents: map.get(key) || 0 });
241251 }
242 const max = Math.max(1, ...series.map((s) => s.cents));
252 // True peak day — the display value. 0 when there was no spend.
253 const max = Math.max(0, ...series.map((s) => s.cents));
254 // Chart scale denominator only. Clamped so an all-zero month still
255 // renders a flat baseline instead of dividing by zero.
256 const scaleMax = Math.max(1, max);
243257 const w = 100;
244258 const h = 100;
245259 const stepX = series.length > 1 ? w / (series.length - 1) : w;
246260 const pts = series.map((s, i) => {
247261 const x = +(i * stepX).toFixed(2);
248 const y = +(h - (s.cents / max) * h).toFixed(2);
262 const y = +(h - (s.cents / scaleMax) * h).toFixed(2);
249263 return `${x},${y}`;
250264 });
251265 const points = pts.join(" ");
Modifiedsrc/routes/dashboard.tsx+12−2View fileUnifiedSplit
4848 type AiSavingsLifetimeReport,
4949} from "../lib/ai-hours-saved";
5050import { totalOpenIssues } from "../lib/issue-counts";
51import { isSiteAdmin } from "../lib/admin";
5152import {
5253 buildRecentActivity,
5354 type RecentActivityItem,
477478 // transient resend feedback (`?verify=sent` / `?verify=rate_limited`)
478479 // and the post-register hint (`?welcome=1`).
479480 const verifyDismissed = getCookie(c, "p2_verify_dismissed") === "1";
481 // Suppressed for site admins: they run this instance (and often haven't
482 // even configured its email provider yet) — nagging them to verify on
483 // their own platform is noise. Checked last so the admin lookup only
484 // runs for unverified, undismissed users.
480485 const showVerifyBanner =
481 !(user as any).emailVerifiedAt && !verifyDismissed;
486 !(user as any).emailVerifiedAt &&
487 !verifyDismissed &&
488 !(await isSiteAdmin(user.id));
482489 const verifyQuery = c.req.query("verify");
483490 const welcomeQuery = c.req.query("welcome");
484491
510517 is written to the server log.
511518 </span>
512519 ) : (
513 <span>Verify your email to keep using Gluecron.</span>
520 <span>
521 Verify your email — it's used for notifications and account
522 recovery.
523 </span>
514524 )}
515525 </div>
516526 <form
517527
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts