Your Stripe MRR and today's revenue above the prompt, refreshed every 15 minutes; /jrmrr adds the last 30 days as a sparkline.

Your Stripe numbers above the prompt, so you see the business while you build it.
mrr 4,320 € · today 149 €
One quiet row, right-aligned, out of the way of your eye.
/jrmrr details opens the detail:
jr mrr updated 14:05
4,320 € 149 € 5,980 €
MRR today last 30 days
▂▃▁▅▂▆▃▂▄▇▂▃▅▆▂▁▃▅▆▇▃▂▄▅▆█▃▂▄▅
30 days ago today
/jrmrr details.Create a restricted key in the Stripe dashboard (Developers → API keys → Create restricted key) with Read on Charges and Subscriptions, nothing else. Paste it in /config under jr-stripemrr. It is stored as a secret.
| Command | |
|---|---|
/jrmrr | Hide or show the band (it toggles); /jrmrr hide and show set it |
/jrmrr details | Open MRR, today and the 30-day sparkline |
/config)| Option | Default | |
|---|---|---|
| Stripe restricted key | Read access to Charges and Subscriptions | |
| Show the band | on | MRR and today above the prompt |
/plugin install jr-stripemrr --marketplace jrlprost/claude-code-mods
Calls api.stripe.com with your restricted key: lists charges of the last 30 days and active subscriptions, read only. Keeps the figures in memory for the session. Writes nothing, runs nothing.
hooks/register.tsx 180 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { JrStripeFigures } from '../types'
5import { gradientText } from './jr/draw'
6import { GRADIENTS, colorAt } from './jr/gradient'
7import type { Charge, Subscription } from './stripe'
8import { dailyRevenue, mainCurrency, money, mrrOf, sparkline } from './stripe'
9
10const PANE = 'jr-stripemrr'
11const REFRESH_MS = 15 * 60_000
12const DAYS = 30
13const API = 'https://api.stripe.com/v1'
14const figures = atom({ plugin: 'jr-stripemrr', key: 'figures' } as const, null)
15const bandHidden = atom({ plugin: 'jr-stripemrr', key: 'isBandHidden' } as const, false)
16
17// The band can be hidden: /jrmrr hide, show, or the bare command to toggle. One choice for
18// every session, kept across restarts.
19const band = { isLoaded: false }
20
21const bandVerb = (args: string) => {
22 const arg = args.trim().toLowerCase()
23 if (arg === 'hide' || arg === 'off') return 'hide' as const
24 if (arg === 'show' || arg === 'on') return 'show' as const
25 return arg === '' ? ('toggle' as const) : null
26}
27
28async function isBandHidden($: EngineInterface) {
29 if (!band.isLoaded) {
30 band.isLoaded = true
31 if ((await $.store.get('isBandHidden')) === true) await update($, bandHidden, () => true)
32 }
33 return read($, bandHidden)
34}
35
36async function setBand($: EngineInterface, verb: 'hide' | 'show' | 'toggle') {
37 const hide = verb === 'toggle' ? !(await isBandHidden($)) : verb === 'hide'
38 await $.store.set('isBandHidden', hide)
39 await update($, bandHidden, () => hide)
40 return { text: hide ? 'jr mrr · band hidden (/jrmrr to bring it back)' : 'jr mrr · band shown' }
41}
42const live = { isStarted: false, key: '' }
43
44/** Every page of a Stripe list, 100 at a time, at most `pages` pages. */
45async function listAll<T>($: EngineInterface, resource: string, query: string, pages = 10): Promise<T[]> {
46 const out: T[] = []
47 let after = ''
48 for (let i = 0; i < pages; i++) {
49 const url = `${API}/${resource}?limit=100&${query}${after ? `&starting_after=${after}` : ''}`
50 const res = await $.http.fetch(url, { headers: { Authorization: `Bearer ${live.key}` } })
51 if (!res.ok) throw new Error(`Stripe answered ${res.status} for ${resource}`)
52 const body = JSON.parse(res.text) as { data: (T & { id: string })[]; has_more: boolean }
53 out.push(...body.data)
54 if (!body.has_more || body.data.length === 0) break
55 after = body.data[body.data.length - 1]!.id
56 }
57 return out
58}
59
60async function refresh($: EngineInterface) {
61 if (!live.key) return
62 const now = await $.clock.now()
63 try {
64 const since = Math.floor((now - DAYS * 86_400_000) / 1000)
65 const [charges, subscriptions] = await Promise.all([
66 listAll<Charge>($, 'charges', `created[gte]=${since}`),
67 listAll<Subscription>($, 'subscriptions', 'status=active'),
68 ])
69 const currency = mainCurrency(charges, subscriptions)
70 const daily = dailyRevenue(charges, currency, now, DAYS)
71 const next: JrStripeFigures = { currency, today: daily[DAYS - 1] ?? 0, mrr: mrrOf(subscriptions, currency), daily, at: now, error: null }
72 await update($, figures, () => next)
73 } catch (error) {
74 await update($, figures, prev => ({ ...(prev ?? { currency: 'usd', today: 0, mrr: 0, daily: [], at: now }), at: now, error: String(error).slice(0, 120) }))
75 }
76}
77
78async function ensureStarted($: EngineInterface) {
79 if (live.isStarted) return
80 live.isStarted = true
81 await $.command.register({ name: 'jrmrr', description: 'Show or hide the MRR band; /jrmrr details opens revenue today, MRR and the last 30 days' })
82 if (!live.key) return
83 await refresh($)
84 $.clock.every(REFRESH_MS, () => {
85 void refresh($)
86 })
87}
88
89export const register: Register = (on, options) => {
90 live.key = String(options.apiKey ?? '')
91 const showBand = options.showBand !== false
92
93 on('session.start', async ($, e, next) => {
94 const started = await next(e)
95 await ensureStarted($)
96 return started
97 })
98
99 on('prompt.submit', async ($, e, next) => {
100 await ensureStarted($)
101 return next(e)
102 })
103
104 on('command.run', { command: 'jrmrr' }, async ($, e) => {
105 await ensureStarted($)
106 const verb = bandVerb(e.args)
107 // Not set up yet: the bare command says how, instead of toggling a band that cannot show.
108 if (verb && (live.key || verb !== 'toggle')) return setBand($, verb)
109 if (!live.key) return { text: 'jr mrr · add a Stripe restricted key (read access to Charges and Subscriptions) in /config under jr-stripemrr.' }
110 await refresh($)
111 await $.ui.open({ id: PANE, title: 'jr mrr', rows: 9 })
112 return { text: 'jr mrr opened.' }
113 })
114
115 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
116 const { Box, Text } = $.ui.resolve(e)
117 const f = await read($, figures)
118 if (!f) return <Text dimColor>Asking Stripe…</Text>
119 const total = f.daily.reduce((a, b) => a + b, 0)
120 const spark = sparkline(f.daily)
121 return (
122 <Box flexDirection="column">
123 <Text>
124 {gradientText(Text, 'jr mrr', GRADIENTS.money)}
125 <Text dimColor>{` updated ${new Date(f.at).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}`}</Text>
126 </Text>
127 {f.error && <Text color="#ef4565">{f.error}</Text>}
128 <Box columnGap={4}>
129 <Box flexDirection="column">
130 <Text bold color="#2cb67d">{money(f.mrr, f.currency)}</Text>
131 <Text dimColor>MRR</Text>
132 </Box>
133 <Box flexDirection="column">
134 <Text bold>{money(f.today, f.currency)}</Text>
135 <Text dimColor>today</Text>
136 </Box>
137 <Box flexDirection="column">
138 <Text bold>{money(total, f.currency)}</Text>
139 <Text dimColor>{`last ${DAYS} days`}</Text>
140 </Box>
141 </Box>
142 <Text>
143 {[...spark].map((c, i) => (
144 <Text color={colorAt(GRADIENTS.money, i / Math.max(1, spark.length - 1))}>{c}</Text>
145 ))}
146 </Text>
147 <Text dimColor>{`${DAYS} days ago${' '.repeat(Math.max(1, DAYS - 17))}today`}</Text>
148 </Box>
149 )
150 })
151
152 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
153 const below = await next(e)
154 // Never write state while drawing: the saved choice is loaded just after.
155 if (!band.isLoaded) {
156 $.clock.after(0, () => {
157 void isBandHidden($)
158 })
159 }
160 if (await read($, bandHidden)) return below
161 const f = await read($, figures)
162 if (!showBand || !f || e.props.hasSurvey) return below
163 const { Box, Text } = $.ui.resolve(e)
164 return (
165 <Box flexDirection="column">
166 <Box justifyContent="flex-end">
167 <Text wrap="truncate-end">
168 <Text dimColor>{'mrr '}</Text>
169 <Text color="#2cb67d">{money(f.mrr, f.currency)}</Text>
170 <Text dimColor>{' · today '}</Text>
171 <Text>{money(f.today, f.currency)}</Text>
172 {f.error && <Text color="#ef4565">{' · Stripe unreachable'}</Text>}
173 </Text>
174 </Box>
175 {below}
176 </Box>
177 )
178 })
179}
180hooks/jr/draw.tsx 35 lines1// Drawing helpers shared by every jr mod. Synced into each plugin's hooks/jr/.
2import type { Elements } from 'claude-code'
3
4import { bar, paint } from './gradient'
5
6type TextElement = Elements['terminal']['Text']
7
8/** `text` in a gradient, one color per character, bold by default. */
9export const gradientText = (Text: TextElement, text: string, stops: readonly string[], bold = true) => (
10 <Text bold={bold}>
11 {paint(text, stops).map(({ char, color }) => (
12 <Text color={color}>{char}</Text>
13 ))}
14 </Text>
15)
16
17/** A gauge: filled cells in the gradient, empty cells dim. */
18export const gauge = (Text: TextElement, ratio: number, width: number, stops: readonly string[]) => (
19 <Text>
20 {bar(ratio, width, stops).map(cell =>
21 cell.empty ? (
22 <Text color={cell.color} dimColor>
23 {cell.char}
24 </Text>
25 ) : (
26 <Text color={cell.color}>{cell.char}</Text>
27 ),
28 )}
29 </Text>
30)
31
32/** The jr tag that opens every band line: a small gradient label. */
33export const tag = (Text: TextElement, label: string, stops: readonly string[]) =>
34 gradientText(Text, `${label} `, stops)
35hooks/jr/gradient.ts 75 lines1// Shared look of every jr mod: one palette, gradients drawn one cell at a time.
2// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh: edit it here only.
3
4export type Rgb = readonly [number, number, number]
5
6const hexToRgb = (hex: string): Rgb => {
7 const n = Number.parseInt(hex.replace('#', ''), 16)
8 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
9}
10
11const rgbToHex = ([r, g, b]: Rgb) =>
12 `#${[r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
13
14export const GRADIENTS = {
15 // The jr signature: violet to pink to amber.
16 brand: ['#7f5af0', '#ff5ca8', '#ffb86b'],
17 // Calm to alarm, for gauges where full is bad.
18 heat: ['#2cb67d', '#f4d35e', '#ff8c42', '#ef4565'],
19 // Cool, for neutral figures.
20 ice: ['#3da9fc', '#7f5af0'],
21 // Money and growth.
22 money: ['#2cb67d', '#a8e6cf'],
23} as const satisfies Record<string, readonly string[]>
24
25export type GradientName = keyof typeof GRADIENTS
26
27/** The color at `t` (0..1) along the stops. */
28export const colorAt = (stops: readonly string[], t: number): string => {
29 if (stops.length === 1) return stops[0]!
30 const clamped = Math.min(1, Math.max(0, t))
31 const span = clamped * (stops.length - 1)
32 const i = Math.min(stops.length - 2, Math.floor(span))
33 const a = hexToRgb(stops[i]!)
34 const b = hexToRgb(stops[i + 1]!)
35 const local = span - i
36 return rgbToHex([0, 1, 2].map(k => a[k]! + (b[k]! - a[k]!) * local) as unknown as Rgb)
37}
38
39/** One color per character of `text`, spread across the stops. */
40export const paint = (text: string, stops: readonly string[]) => {
41 const chars = [...text]
42 return chars.map((char, i) => ({ char, color: colorAt(stops, chars.length > 1 ? i / (chars.length - 1) : 0) }))
43}
44
45const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
46
47/**
48 * A bar `width` cells wide filled to `ratio` (0..1), with eighth-cell precision.
49 * Each filled cell takes the gradient color of its position along the whole bar,
50 * so a nearly full heat bar ends red and a low one stays green.
51 */
52export const bar = (ratio: number, width: number, stops: readonly string[]) => {
53 const filled = Math.min(1, Math.max(0, ratio)) * width
54 const full = Math.floor(filled)
55 const partial = EIGHTHS[Math.round((filled - full) * 7)] ?? ''
56 const cells: { char: string; color: string; empty?: true }[] = []
57 for (let i = 0; i < width; i++) {
58 const color = colorAt(stops, width > 1 ? i / (width - 1) : 0)
59 if (i < full) cells.push({ char: '█', color })
60 else if (i === full && partial) cells.push({ char: partial, color })
61 else cells.push({ char: '·', color, empty: true })
62 }
63 return cells
64}
65
66export const formatTokens = (n: number) =>
67 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
68
69export const formatDuration = (ms: number) => {
70 const minutes = Math.max(0, Math.round(ms / 60_000))
71 const h = Math.floor(minutes / 60)
72 const m = minutes % 60
73 return h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`
74}
75hooks/stripe.ts 72 lines1// Pure figures from Stripe objects. Amounts stay in the currency's smallest unit until display.
2
3export type Charge = { amount: number; amount_refunded: number; currency: string; status: string; paid: boolean; created: number }
4
5export type SubscriptionItem = {
6 quantity?: number
7 price?: { unit_amount: number | null; currency: string; recurring: { interval: 'day' | 'week' | 'month' | 'year'; interval_count: number } | null }
8}
9export type Subscription = { status: string; items: { data: SubscriptionItem[] } }
10
11const ZERO_DECIMAL = new Set(['bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf'])
12
13/** The smallest unit as a whole amount: cents to euros, yen stays yen. */
14export const major = (minor: number, currency: string) => (ZERO_DECIMAL.has(currency.toLowerCase()) ? minor : minor / 100)
15
16const PER_MONTH = { day: 365 / 12, week: 52 / 12, month: 1, year: 1 / 12 } as const
17
18/** One subscription item's amount per month, in the smallest unit. */
19export const itemMonthly = (item: SubscriptionItem) => {
20 const p = item.price
21 if (!p?.recurring || p.unit_amount === null) return 0
22 return (p.unit_amount * (item.quantity ?? 1) * PER_MONTH[p.recurring.interval]) / Math.max(1, p.recurring.interval_count)
23}
24
25/** Monthly recurring revenue of active and trialing-free subscriptions, in `currency` only. */
26export const mrrOf = (subscriptions: readonly Subscription[], currency: string) =>
27 subscriptions
28 .filter(s => s.status === 'active' || s.status === 'past_due')
29 .flatMap(s => s.items.data)
30 .filter(i => i.price?.currency === currency)
31 .reduce((sum, i) => sum + itemMonthly(i), 0)
32
33/** Net amount of a succeeded, paid charge: captured minus refunded. */
34export const netOf = (c: Charge) => (c.status === 'succeeded' && c.paid ? c.amount - c.amount_refunded : 0)
35
36/** The currency most charges and subscriptions use. */
37export const mainCurrency = (charges: readonly Charge[], subscriptions: readonly Subscription[]) => {
38 const counts = new Map<string, number>()
39 for (const c of charges) counts.set(c.currency, (counts.get(c.currency) ?? 0) + 1)
40 for (const s of subscriptions) for (const i of s.items.data) if (i.price) counts.set(i.price.currency, (counts.get(i.price.currency) ?? 0) + 1)
41 return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'usd'
42}
43
44/** Net revenue per local day for the `days` days ending today, oldest first, in `currency`. */
45export const dailyRevenue = (charges: readonly Charge[], currency: string, now: number, days: number) => {
46 const start = new Date(now)
47 start.setHours(0, 0, 0, 0)
48 const first = start.getTime() - (days - 1) * 86_400_000
49 const out = Array.from({ length: days }, () => 0)
50 for (const c of charges) {
51 if (c.currency !== currency) continue
52 const i = Math.floor((c.created * 1000 - first) / 86_400_000)
53 if (i >= 0 && i < days) out[i]! += netOf(c)
54 }
55 return out
56}
57
58const SYMBOLS: Record<string, string> = { usd: '$', eur: '€', gbp: '£', jpy: '¥', cad: 'CA$', aud: 'A$', chf: 'CHF ' }
59
60export const money = (minor: number, currency: string) => {
61 const amount = major(minor, currency)
62 const n = amount >= 1000 ? Math.round(amount).toLocaleString('en-US') : amount.toFixed(amount % 1 === 0 ? 0 : 2)
63 const symbol = SYMBOLS[currency.toLowerCase()]
64 return symbol ? (symbol.length === 1 && currency !== 'eur' ? `${symbol}${n}` : `${n} ${symbol.trim()}`) : `${n} ${currency.toUpperCase()}`
65}
66
67const SPARK = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
68export const sparkline = (values: readonly number[]) => {
69 const most = Math.max(...values, 0)
70 return values.map(v => (most <= 0 ? SPARK[0] : SPARK[Math.min(7, Math.round((v / most) * 7))])).join('')
71}
72types/index.d.ts 18 lines1export type JrStripeFigures = {
2 currency: string
3 /** Net revenue today, smallest currency unit. */
4 today: number
5 /** Monthly recurring revenue, smallest currency unit. */
6 mrr: number
7 /** Net revenue per day, last 30 days, oldest first. */
8 daily: number[]
9 at: number
10 error: string | null
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'jr-stripemrr': { figures: JrStripeFigures | null; isBandHidden: boolean }
16 }
17}
18