SLOPSHOPPER

Client Ledger

Bill clients for Claude Code work: per-client time, tokens and proof of work, exported as invoice-ready logs

newpanebandguardcommandprompt
v0.1.0MITupdated 2026-10-08ahmedgcompany-cyber/client-ledger
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · client-ledger
│ ┃ Client Ledger ✕ › fix the failing auth test and add an audit log call │ ┃ Client Today Week Month │ ┃ Billable ⏺ Read(src/auth.ts) │ ┃ No tracked work this month. Set a client ⎿ Read 6 lines │ ┃ with /client name. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ /ledger export [client] [YYYY-MM] writes an ⏺ Bash(bun test) │ ┃ invoice-ready log. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /client │ ⎿ client-ledger: No client set. │ ⎿ client-ledger: No clients yet: /client <name> adds one. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Client Ledger
Client Today Week Month Billable No tracked work this month. Set a client with /client name. /ledger export [client] [YYYY-MM] writes an invoice-ready log.
README

Client Ledger

A Claude Code mod for freelancers and agencies: it bills your Claude Code work to clients.

It records which client each task was for, how long it took, how many tokens it used and what was done (files changed, commands run). Then it exports an invoice-ready work log per client and month.

What it does

  • Client tagging: /client Acme sets who you're billing. The folder is remembered, so the next session there starts as Acme. /client pause, /client resume and /client off also work, and /client alone lists your clients.
  • Time and token meter: each prompt is one task. It counts Claude's working time plus your own time between prompts, capped by an idle limit, and the tokens the turn used, subagents included.
  • Proof of work: every task keeps the prompt's first line, each file Claude edited or wrote, and each shell command with pass or fail.
  • Live band: a row above the prompt shows ● Acme · today 1h 05m · 84k tok · 3 tasks · 108.00 USD, with Pause and Ledger buttons.
  • Ledger pane: /ledger shows per-client totals for today, this week and this month, with the billable amount.
  • Exports: /ledger export [client] [YYYY-MM] writes ledger-<client>-<month>.md (a dated task list with hours, amount and proof) and a matching .csv for spreadsheets and accounting tools.

Install

You need Claude Code 2.1.287 or later (mods support).

git clone https://github.com/ahmedgcompany-cyber/client-ledger
claude --plugin-dir ./client-ledger

Or add the folder to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (env block) to load it in every session, the desktop app included.

Settings

These are set in /config, or under pluginConfigs.client-ledger.options in settings.

SettingDefaultMeaning
hourlyRate0What you bill per tracked hour
currencyUSDCurrency code on exports
tokenRatePerMillion0What you bill per million tokens, on top of hours
idleMinutes10Gaps between prompts count at most this long as your own time
exportDirledger-exportsWhere exports are written

Data

Entries are saved as JSON lines, one file per day, in ~/.claude/client-ledger/YYYY-MM-DD.jsonl. Nothing leaves your machine.

Limits

  • "Your time" is an estimate: the gap since Claude's last answer, capped by idleMinutes.
  • Proof covers the Edit, Write and Bash tools.
  • The mod API is early access, so a Claude Code update may need a mod update.

Development

claude plugin validate .
claude plugin test .

License

MIT

Source 3 files
hooks/register.tsx 280 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Entry } from '../types'
5import {
6  addCommand,
7  addFile,
8  addUsage,
9  amount,
10  cleanClient,
11  dayKey,
12  emptyTotals,
13  finish,
14  fmtDuration,
15  fmtTokens,
16  monthKey,
17  monthStart,
18  newEntry,
19  parseJsonl,
20  slug,
21  summarize,
22  toCsv,
23  toMarkdown,
24  totalsOf,
25  weekStart,
26  type Billing,
27} from './ledger'
28
29const PANE = 'client-ledger'
30const active = atom({ plugin: 'client-ledger', key: 'active' } as const, { client: null, paused: false })
31const current = atom({ plugin: 'client-ledger', key: 'current' } as const, null)
32const today = atom({ plugin: 'client-ledger', key: 'today' } as const, emptyTotals())
33const summary = atom({ plugin: 'client-ledger', key: 'summary' } as const, [])
34
35type Engine = EngineInterface
36
37let lastEnd: number | null = null
38
39async function ledgerDir($: Engine): Promise<string> {
40  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
41  return `${home.replace(/\\/g, '/')}/.claude/client-ledger`
42}
43
44async function readText($: Engine, path: string): Promise<string> {
45  try {
46    return await $.fs.read(path)
47  } catch {
48    return ''
49  }
50}
51
52/** Every entry in day files from `since` on. */
53async function loadSince($: Engine, since: number): Promise<Entry[]> {
54  const dir = await ledgerDir($)
55  const files = await $.fs.list(dir).catch(() => [])
56  const first = dayKey(since)
57  const names = files
58    .map(f => f.name)
59    .filter(n => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(n) && n.slice(0, 10) >= first)
60  const texts = await Promise.all(names.map(n => readText($, `${dir}/${n}`)))
61  return texts.flatMap(parseJsonl)
62}
63
64async function loadMonth($: Engine, month: string): Promise<Entry[]> {
65  const dir = await ledgerDir($)
66  const files = await $.fs.list(dir).catch(() => [])
67  const names = files.map(f => f.name).filter(n => n.startsWith(`${month}-`) && n.endsWith('.jsonl'))
68  const texts = await Promise.all(names.map(n => readText($, `${dir}/${n}`)))
69  return texts.flatMap(parseJsonl)
70}
71
72async function appendEntry($: Engine, entry: Entry): Promise<void> {
73  const path = `${await ledgerDir($)}/${dayKey(entry.start)}.jsonl`
74  const before = await readText($, path)
75  await $.fs.write(path, before + JSON.stringify(entry) + '\n')
76}
77
78async function refreshToday($: Engine): Promise<void> {
79  const { client } = await read($, active)
80  const now = await $.clock.now()
81  const path = `${await ledgerDir($)}/${dayKey(now)}.jsonl`
82  const mine = parseJsonl(await readText($, path)).filter(e => e.client === client)
83  await update($, today, () => totalsOf(mine))
84}
85
86async function setClient($: Engine, client: string | null): Promise<void> {
87  await update($, active, a => ({ ...a, client, paused: false }))
88  await update($, current, () => null)
89  const cwd = await $.session.cwd()
90  const folders = ((await $.store.get('folders')) ?? {}) as Record<string, string>
91  if (client === null) delete folders[cwd]
92  else folders[cwd] = client
93  await $.store.set('folders', folders)
94  if (client !== null) {
95    const clients = ((await $.store.get('clients')) ?? []) as string[]
96    if (!clients.includes(client)) await $.store.set('clients', [...clients, client].sort())
97  }
98  await refreshToday($)
99}
100
101async function openLedger($: Engine): Promise<void> {
102  const now = await $.clock.now()
103  const entries = await loadSince($, Math.min(weekStart(now), monthStart(now)))
104  await update($, summary, () => summarize(entries, now))
105  await $.ui.open({ id: PANE, title: 'Client Ledger' })
106}
107
108export const register: Register = (on, options) => {
109  const billing: Billing = {
110    hourlyRate: Number(options.hourlyRate ?? 0),
111    tokenRatePerMillion: Number(options.tokenRatePerMillion ?? 0),
112    currency: String(options.currency ?? 'USD'),
113  }
114  const idleMinutes = Number(options.idleMinutes ?? 10)
115  const exportDir = String(options.exportDir ?? '').trim() || 'ledger-exports'
116
117  on('session.start', async ($, e, next) => {
118    await $.command.register({
119      name: 'client',
120      description: 'Set the client this work is billed to',
121      argumentHint: '[name | off | pause | resume]',
122    })
123    await $.command.register({
124      name: 'ledger',
125      description: 'Show billed time per client, or export a client’s month',
126      argumentHint: '[export [client] [YYYY-MM]]',
127    })
128    const { client } = await read($, active)
129    if (client === null) {
130      const folders = ((await $.store.get('folders')) ?? {}) as Record<string, string>
131      const mapped = folders[await $.session.cwd()]
132      if (mapped !== undefined) await update($, active, a => ({ ...a, client: mapped }))
133    }
134    await refreshToday($)
135
136    return next(e)
137  })
138
139  on('command.run', { command: 'client' }, async ($, e) => {
140    const arg = e.args.trim()
141    const { client, paused } = await read($, active)
142
143    if (arg === '') {
144      const clients = ((await $.store.get('clients')) ?? []) as string[]
145      const now = client === null ? 'No client set.' : `Billing ${client}${paused ? ' (paused)' : ''}.`
146      const known = clients.length === 0 ? 'No clients yet: /client <name> adds one.' : `Clients: ${clients.join(', ')}`
147      return { text: `${now}\n${known}` }
148    }
149    if (arg === 'off') {
150      await setClient($, null)
151      return { text: 'Stopped tracking. This folder is no longer tied to a client.' }
152    }
153    if (arg === 'pause' || arg === 'resume') {
154      if (client === null) return { text: 'No client set: /client <name> first.' }
155      await update($, active, a => ({ ...a, paused: arg === 'pause' }))
156      return { text: arg === 'pause' ? `Paused billing ${client}.` : `Resumed billing ${client}.` }
157    }
158
159    const name = cleanClient(arg)
160    await setClient($, name)
161    return { text: `Billing ${name}. This folder will open as ${name} next time.` }
162  })
163
164  on('command.run', { command: 'ledger' }, async ($, e) => {
165    const words = e.args.trim().split(/\s+/).filter(w => w !== '')
166    if (words[0] !== 'export') {
167      await openLedger($)
168      return { text: 'Client Ledger opened.' }
169    }
170
171    const rest = words.slice(1)
172    const now = await $.clock.now()
173    const month = rest.length > 0 && /^\d{4}-\d{2}$/.test(rest[rest.length - 1]!) ? rest.pop()! : monthKey(now)
174    const client = rest.length > 0 ? cleanClient(rest.join(' ')) : (await read($, active)).client
175    if (client === null) return { text: 'Which client? /ledger export <client> [YYYY-MM]' }
176
177    const entries = await loadMonth($, month)
178    if (!entries.some(x => x.client === client)) return { text: `No tracked work for ${client} in ${month}.` }
179
180    const base = `${exportDir.replace(/\\/g, '/').replace(/\/$/, '')}/ledger-${slug(client)}-${month}`
181    await $.fs.write(`${base}.md`, toMarkdown(entries, client, month, billing))
182    await $.fs.write(`${base}.csv`, toCsv(entries, client, month, billing))
183    return { text: `Exported ${client}, ${month}:\n${base}.md\n${base}.csv` }
184  })
185
186  on('prompt.submit', async ($, e, next) => {
187    const { client, paused } = await read($, active)
188    const isOwnTurn = e.turnId === undefined && !e.text.trimStart().startsWith('/')
189    if (client !== null && !paused && isOwnTurn) {
190      const now = await $.clock.now()
191      await update($, current, () => newEntry(client, e.text, now, lastEnd, idleMinutes))
192    }
193
194    return next(e)
195  })
196
197  on('tool.call', async ($, e, next) => {
198    const ran = await next(e)
199    if ((await read($, current)) === null || ran.deny !== undefined) return ran
200
201    if ((e.tool === 'Edit' || e.tool === 'Write') && ran.isError !== true) {
202      await update($, current, c => (c === null ? c : addFile(c, e.file_path)))
203    } else if (e.tool === 'Bash') {
204      await update($, current, c => (c === null ? c : addCommand(c, e.command, ran.isError !== true)))
205    }
206
207    return ran
208  })
209
210  on('turn.complete', async ($, e, next) => {
211    const entry = await read($, current)
212    if (entry !== null) {
213      const u = e.usage
214      let done = u ? addUsage(entry, u.input_tokens + u.cache_creation_input_tokens, u.output_tokens, u.model) : entry
215      if (e.agentId === undefined) {
216        const now = await $.clock.now()
217        done = finish(done, e.durationMs, now)
218        lastEnd = now
219        await update($, current, () => null)
220        await appendEntry($, done)
221        await refreshToday($)
222      } else {
223        await update($, current, () => done)
224      }
225    }
226
227    return next(e)
228  })
229
230  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
231    const { client, paused } = await read($, active)
232    if (client === null || e.props.hasSurvey) return next(e)
233
234    const t = await read($, today)
235    const { Box, Button, Text } = $.ui.resolve(e)
236    const money = billing.hourlyRate > 0 || billing.tokenRatePerMillion > 0
237      ? ` · ${amount(t, billing).toFixed(2)} ${billing.currency}`
238      : ''
239
240    return (
241      <Box>
242        <Text color={paused ? undefined : 'green'} dimColor={paused}>
243          {paused ? '○' : '●'} {client}
244        </Text>
245        <Text dimColor>
246          {' '}· today {fmtDuration(t.ms)} · {fmtTokens(t.tokens)} tok · {t.tasks} tasks{money}{' '}
247        </Text>
248        <Button
249          key="pause"
250          label={paused ? 'Resume' : 'Pause'}
251          dimColor
252          onPress={() => update($, active, a => ({ ...a, paused: !a.paused }))}
253        />
254        <Button key="ledger" label="Ledger" dimColor onPress={() => openLedger($)} />
255      </Box>
256    )
257  })
258
259  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
260    const { Box, Text } = $.ui.resolve(e)
261    const rows = await read($, summary)
262    const { client } = await read($, active)
263
264    return (
265      <Box flexDirection="column">
266        <Text bold>Client          Today      Week       Month      Billable</Text>
267        {rows.length === 0 && <Text dimColor>No tracked work this month. Set a client with /client name.</Text>}
268        {rows.map(r => (
269          <Text color={r.client === client ? 'green' : undefined}>
270            {r.client.padEnd(15).slice(0, 15)} {fmtDuration(r.today.ms).padEnd(10)} {fmtDuration(r.week.ms).padEnd(10)}{' '}
271            {fmtDuration(r.month.ms).padEnd(10)} {amount(r.month, billing).toFixed(2)} {billing.currency}
272          </Text>
273        ))}
274        <Text dimColor> </Text>
275        <Text dimColor>/ledger export [client] [YYYY-MM] writes an invoice-ready log.</Text>
276      </Box>
277    )
278  })
279}
280
hooks/ledger.ts 208 lines
1import type { ClientSummary, Entry, Totals } from '../types'
2
3const DAY_MS = 86_400_000
4
5export type Billing = { hourlyRate: number; tokenRatePerMillion: number; currency: string }
6
7export const pad = (n: number): string => String(n).padStart(2, '0')
8
9/** Local calendar day, `YYYY-MM-DD`: the name of that day's ledger file. */
10export const dayKey = (ms: number): string => {
11  const d = new Date(ms)
12  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
13}
14
15export const monthKey = (ms: number): string => dayKey(ms).slice(0, 7)
16
17/** Start of the local day / Monday-based week / month that holds `ms`. */
18export const dayStart = (ms: number): number => {
19  const d = new Date(ms)
20  return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
21}
22export const weekStart = (ms: number): number => {
23  const day = (new Date(ms).getDay() + 6) % 7
24  return dayStart(ms) - day * DAY_MS
25}
26export const monthStart = (ms: number): number => {
27  const d = new Date(ms)
28  return new Date(d.getFullYear(), d.getMonth(), 1).getTime()
29}
30
31/** Normalises a client name typed by the person: trimmed, inner spaces collapsed. */
32export const cleanClient = (raw: string): string => raw.trim().replace(/\s+/g, ' ').slice(0, 60)
33
34export const slug = (s: string): string =>
35  s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'client'
36
37export const newEntry = (
38  client: string,
39  prompt: string,
40  now: number,
41  lastEnd: number | null,
42  idleMinutes: number,
43): Entry => {
44  const gap = lastEnd === null ? 0 : Math.max(0, now - lastEnd)
45  const firstLine = prompt.trim().split('\n')[0] ?? ''
46  return {
47    id: `${now.toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
48    client,
49    start: now,
50    end: now,
51    aiMs: 0,
52    humanMs: Math.min(gap, idleMinutes * 60_000),
53    tokensIn: 0,
54    tokensOut: 0,
55    task: firstLine.slice(0, 200),
56    files: [],
57    commands: [],
58  }
59}
60
61export const addFile = (entry: Entry, path: string): Entry =>
62  entry.files.includes(path) ? entry : { ...entry, files: [...entry.files, path].slice(-100) }
63
64export const addCommand = (entry: Entry, cmd: string, ok: boolean): Entry => ({
65  ...entry,
66  commands: [...entry.commands, { cmd: cmd.trim().split('\n')[0]!.slice(0, 160), ok }].slice(-100),
67})
68
69export const addUsage = (entry: Entry, tokensIn: number, tokensOut: number, model?: string): Entry => ({
70  ...entry,
71  tokensIn: entry.tokensIn + tokensIn,
72  tokensOut: entry.tokensOut + tokensOut,
73  model: model ?? entry.model,
74})
75
76export const finish = (entry: Entry, aiMs: number, now: number): Entry => ({
77  ...entry,
78  aiMs: entry.aiMs + aiMs,
79  end: now,
80})
81
82export const entryMs = (e: Entry): number => e.aiMs + e.humanMs
83export const entryTokens = (e: Entry): number => e.tokensIn + e.tokensOut
84
85export const emptyTotals = (): Totals => ({ ms: 0, tokens: 0, tasks: 0 })
86
87export const addToTotals = (t: Totals, e: Entry): Totals => ({
88  ms: t.ms + entryMs(e),
89  tokens: t.tokens + entryTokens(e),
90  tasks: t.tasks + 1,
91})
92
93export const totalsOf = (entries: readonly Entry[]): Totals => entries.reduce(addToTotals, emptyTotals())
94
95/** Per-client totals for today, this week and this month, busiest month first. */
96export const summarize = (entries: readonly Entry[], now: number): ClientSummary[] => {
97  const d = dayStart(now)
98  const w = weekStart(now)
99  const m = monthStart(now)
100  const by = new Map<string, ClientSummary>()
101  for (const e of entries) {
102    if (e.start < m) continue
103    const s = by.get(e.client) ?? { client: e.client, today: emptyTotals(), week: emptyTotals(), month: emptyTotals() }
104    s.month = addToTotals(s.month, e)
105    if (e.start >= w) s.week = addToTotals(s.week, e)
106    if (e.start >= d) s.today = addToTotals(s.today, e)
107    by.set(e.client, s)
108  }
109  return [...by.values()].sort((a, b) => b.month.ms - a.month.ms)
110}
111
112export const parseJsonl = (text: string): Entry[] =>
113  text
114    .split('\n')
115    .filter(line => line.trim() !== '')
116    .flatMap(line => {
117      try {
118        return [JSON.parse(line) as Entry]
119      } catch {
120        return []
121      }
122    })
123
124export const fmtDuration = (ms: number): string => {
125  const mins = Math.round(ms / 60_000)
126  const h = Math.floor(mins / 60)
127  const m = mins % 60
128  return h > 0 ? `${h}h ${pad(m)}m` : `${m}m`
129}
130
131export const fmtTokens = (n: number): string =>
132  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
133
134export const hours = (ms: number): number => Math.round((ms / 3_600_000) * 100) / 100
135
136export const amount = (t: Totals, b: Billing): number =>
137  Math.round((hours(t.ms) * b.hourlyRate + (t.tokens / 1_000_000) * b.tokenRatePerMillion) * 100) / 100
138
139const fmtTime = (ms: number): string => {
140  const d = new Date(ms)
141  return `${pad(d.getHours())}:${pad(d.getMinutes())}`
142}
143
144/** Invoice-ready work log for one client and month, newest last. */
145export const toMarkdown = (entries: readonly Entry[], client: string, month: string, b: Billing): string => {
146  const mine = entries.filter(e => e.client === client && monthKey(e.start) === month).sort((x, y) => x.start - y.start)
147  const t = totalsOf(mine)
148  const lines: string[] = [
149    `# Work log: ${client}, ${month}`,
150    '',
151    `| Tasks | Time | Hours | Tokens | Amount |`,
152    `| --- | --- | --- | --- | --- |`,
153    `| ${t.tasks} | ${fmtDuration(t.ms)} | ${hours(t.ms)} | ${fmtTokens(t.tokens)} | ${amount(t, b).toFixed(2)} ${b.currency} |`,
154    '',
155  ]
156  if (b.hourlyRate > 0 || b.tokenRatePerMillion > 0) {
157    lines.push(
158      `Rates: ${b.hourlyRate} ${b.currency}/hour` +
159        (b.tokenRatePerMillion > 0 ? `, ${b.tokenRatePerMillion} ${b.currency} per million tokens` : ''),
160      '',
161    )
162  }
163  let day = ''
164  for (const e of mine) {
165    const dk = dayKey(e.start)
166    if (dk !== day) {
167      day = dk
168      lines.push(`## ${dk}`, '')
169    }
170    lines.push(`### ${fmtTime(e.start)} · ${fmtDuration(entryMs(e))} · ${e.task || '(no description)'}`)
171    for (const f of e.files) lines.push(`- changed \`${f}\``)
172    for (const c of e.commands) lines.push(`- ran \`${c.cmd}\`${c.ok ? '' : ' (failed)'}`)
173    lines.push('')
174  }
175  if (mine.length === 0) lines.push('_No tracked work this month._', '')
176  return lines.join('\n')
177}
178
179const csvCell = (v: string | number): string => {
180  const s = String(v)
181  return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
182}
183
184export const toCsv = (entries: readonly Entry[], client: string, month: string, b: Billing): string => {
185  const header = ['date', 'start', 'end', 'hours', 'ai_minutes', 'human_minutes', 'tokens', 'amount', 'currency', 'task', 'files', 'commands']
186  const rows = entries
187    .filter(e => e.client === client && monthKey(e.start) === month)
188    .sort((x, y) => x.start - y.start)
189    .map(e => {
190      const t = addToTotals(emptyTotals(), e)
191      return [
192        dayKey(e.start),
193        fmtTime(e.start),
194        fmtTime(e.end),
195        hours(t.ms),
196        Math.round(e.aiMs / 60_000),
197        Math.round(e.humanMs / 60_000),
198        t.tokens,
199        amount(t, b).toFixed(2),
200        b.currency,
201        e.task,
202        e.files.join(' '),
203        e.commands.map(c => c.cmd).join(' ; '),
204      ].map(csvCell).join(',')
205    })
206  return [header.join(','), ...rows].join('\n') + '\n'
207}
208
types/index.d.ts 43 lines
1/** One prompt's worth of work, attributed to a client. */
2export type Entry = {
3  id: string
4  client: string
5  /** Prompt submitted, ms since epoch. */
6  start: number
7  /** Turn finished, ms since epoch. */
8  end: number
9  /** Time Claude spent working on the turn. */
10  aiMs: number
11  /** Your own time before the prompt (reviewing, typing), capped by the idle setting. */
12  humanMs: number
13  tokensIn: number
14  tokensOut: number
15  model?: string
16  /** First line of the prompt, cut to 200 characters. */
17  task: string
18  files: string[]
19  commands: { cmd: string; ok: boolean }[]
20}
21
22export type Totals = { ms: number; tokens: number; tasks: number }
23
24export type ClientSummary = {
25  client: string
26  today: Totals
27  week: Totals
28  month: Totals
29}
30
31export type Active = { client: string | null; paused: boolean }
32
33declare module 'claude-code' {
34  interface PluginState {
35    'client-ledger': {
36      active: Active
37      current: Entry | null
38      today: Totals
39      summary: ClientSummary[]
40    }
41  }
42}
43