SLOPSHOPPER

jr-stripemrr

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

newpanebandcommandpromptnetwork
★ 2v0.3.0MITupdated 2026-10-16jrlprost/claude-code-mods/plugins/jr-stripemrr
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-stripemrr
│ ┃ jr-stripemrr ✕ › fix the failing auth test and add an audit log call │ ┃ Asking Stripe… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /jrmrr │ ⎿ jr-stripemrr: jr mrr · add a Stripe restricted key (read access │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · jr-stripemrr
Asking Stripe…
README

jr-stripemrr

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
  • MRR adds up active (and past-due) subscriptions, every item's price × quantity brought to a month: yearly ÷ 12, weekly × 52 ÷ 12, every N months ÷ N. Discounts and coupons are not taken off.
  • Revenue is succeeded charges minus refunds, per local day.
  • When your account uses several currencies, the figures are in the one most used; the others are left out.
  • Figures refresh every 15 minutes, and when you open /jrmrr details.

Setup

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.

Commands

Command
/jrmrrHide or show the band (it toggles); /jrmrr hide and show set it
/jrmrr detailsOpen MRR, today and the 30-day sparkline

Settings (/config)

OptionDefault
Stripe restricted keyRead access to Charges and Subscriptions
Show the bandonMRR and today above the prompt

Install

/plugin install jr-stripemrr --marketplace jrlprost/claude-code-mods

Access

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.

Source 5 files
hooks/register.tsx 180 lines
1import { 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}
180
hooks/jr/draw.tsx 35 lines
1// 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)
35
hooks/jr/gradient.ts 75 lines
1// 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}
75
hooks/stripe.ts 72 lines
1// 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}
72
types/index.d.ts 18 lines
1export 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