Files
Thomas DurieuxandClaude Opus 5.5 511dcd7067 feat(admin): billing tab to monitor subscriptions and sales (#952)
* feat(admin): add a Billing tab for subscriptions and sales

The admin can now follow subscriptions and sales without opening Stripe:
- Summary tiles: active subscriptions (monthly/annual), MRR with annual
  plans counted as 1/12, sales and revenue for 7d/30d/90d/12m/all time,
  all-time sales, subscriptions cancelling at period end, and refunds
  and disputes.
- A stacked chart of sales per day, week or month by plan family.
- A by-plan table: active grants, period and all-time sales and revenue.
- A paginated list of grants with plan, Stripe status, active/expired/
  revoked and user filters, linked to the admin user, repository or
  conference.
- One row of webhook health: pending, failed, oldest pending event, last
  error and unreviewed payment reversals.

A sale is one checkout or subscription, so a paper pack's three grants
count once. Revenue is each sale's Stripe list price, resolved once per
price and cached in memory. Currencies are never added together. Without
Stripe the page shows counts and marks revenue as unavailable.

GET /api/admin/billing/summary and /grants sit behind the admin check.
Grants get a { purchasedAt, _id } index for the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: preserve payment history in admin billing totals

* fix: scope billing history and preserve original sale prices

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 14:37:29 +01:00

226 lines
20 KiB
HTML

<div class="container paper-page admin-page">
<div class="paper-crumbs"><a href="/admin/">Admin</a> &nbsp;/&nbsp; <a href="/admin/users">Users</a> &nbsp;/&nbsp; <span class="here">{{ userInfo?.username || 'Profile' }}</span></div>
<h1 class="paper-page-title">{{ userInfo?.username || 'User' }}</h1>
<nav class="admin-nav">
<a href="/admin/"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-chart"></use></svg> Overview</a>
<a href="/admin/repositories"><svg class="ui-icon" aria-hidden="true"><use href="#project-type-repo"></use></svg> Repositories</a>
<a href="/admin/users" class="active"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-users"></use></svg> Users</a>
<a href="/admin/conferences"><svg class="ui-icon" aria-hidden="true"><use href="#project-icon-conference"></use></svg> Conferences</a>
<a href="/admin/billing"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-card"></use></svg> Billing</a>
<a href="/admin/queues"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-list"></use></svg> Queues</a>
<a href="/admin/errors"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-bug"></use></svg> Errors</a>
</nav>
<div class="user-detail-card" v-if="userInfo">
<div class="user-header">
<img width="56" height="56" v-if="userInfo?.photo" :src="safeUrl((userInfo?.photo))">
<div>
<div class="user-header-status">
<span class="status-dot-wrap">
<span class="status-dot" :class="'st-' + (userInfo?.status || 'unknown')"></span>
<span v-text="fmt?.title(userInfo?.status)"></span>
</span>
<span class="type-badge type-repo" v-if="userInfo?.isAdmin">Admin</span>
</div>
<div class="user-actions" style="margin-top: 4px;">
<button class="btn btn-sm btn-outline-danger" v-if="userInfo?.status !== &#x27;banned&#x27; &amp;&amp; userInfo?.username !== user?.username" @click="banUser()"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-ban"></use></svg> Ban</button>
<button class="btn btn-sm" v-if="userInfo?.status === &#x27;banned&#x27; || userInfo?.status === &#x27;removed&#x27;" @click="activateUser()"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-check-circle"></use></svg> Activate</button>
<button class="btn btn-sm" v-if="!userInfo?.isAdmin" @click="promoteUser()"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-user-shield"></use></svg> Promote to admin</button>
<button class="btn btn-sm btn-outline-ink" v-if="userInfo?.isAdmin &amp;&amp; userInfo?.username !== user?.username" @click="demoteUser()"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-user-minus"></use></svg> Remove admin</button>
</div>
</div>
</div>
<div class="user-detail-grid">
<div class="detail-label">ID</div>
<div class="detail-value mono-id">{{ userInfo?._id }}</div>
<div class="detail-label">Email</div>
<div class="detail-value" :class="{ 'is-empty': !userInfo?.emails?.[0]?.email }">{{ userInfo?.emails?.[0]?.email || 'Not shared' }}</div>
<div class="detail-label">Access token</div>
<div class="detail-value" :class="{ 'is-empty': !userInfo?.accessTokens?.github }" style="font-family: var(--font-mono); font-size: 0.85rem;">{{ userInfo?.accessTokens?.github || 'Not stored' }}</div>
<div class="detail-label">GitHub</div>
<div class="detail-value">
<a target="_blank" :href="safeUrl(&quot;https://github.com/&quot; + (userInfo?.username))">
<i class="fab fa-github"></i> {{ userInfo?.username }}
</a>
</div>
<div class="detail-label">Created</div>
<div class="detail-value">{{ fmt?.humanTime(userInfo?.dateOfEntry) }}</div>
<div class="detail-label">Last connection</div>
<div class="detail-value">
<span v-if="userInfo?.accessTokenDates?.github">{{ fmt?.humanTime(userInfo?.accessTokenDates?.github) }}</span>
<span class="text-muted" v-if="!userInfo?.accessTokenDates?.github">Not recorded (GitHub App sign-in)</span>
</div>
<div class="detail-label">GitHub repos</div>
<div class="detail-value">
{{ userInfo?.repositories?.length ? userInfo.repositories.length + " repositories" : "Not synced" }}
<button class="btn btn-sm ml-2" v-if="userInfo?.repositories?.length" @click="showRepos = !showRepos">
{{ showRepos ? 'Hide repos' : 'Show repos' }}
</button>
<button class="btn btn-sm ml-1" @click="getGitHubRepositories()">
<svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-refresh"></use></svg> Refresh
</button>
</div>
</div>
<div style="margin-top: 20px" v-if="showRepos">
<div class="paper-section-eyebrow">GitHub repositories</div>
<div class="paper-table w-100" style="margin-top: 10px;">
<div class="paper-table-row" style="grid-template-columns: 1fr 160px;" v-for="(repo, index) in userInfo?.repositories">
<div class="cell-anon" role="cell">
<svg class="ui-icon record-type-icon" role="img" aria-label="Repository"><use href="#project-type-repo"></use></svg>
<div class="anon-text">
<span class="repo-name" v-text="repo?.name"></span>
</div>
</div>
<div class="cell-expires" role="cell">
<svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-database"></use></svg> {{ fmt?.humanFileSize(repo?.size) }}
</div>
</div>
</div>
</div>
</div>
<div class="admin-section-header" v-if="userInfo &amp;&amp; userInfo?.isAdmin &amp;&amp; user &amp;&amp; user?.username == userInfo?.username">
<h2><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-key"></use></svg> API tokens</h2>
<span class="section-count">{{ tokens?.length }}</span>
</div>
<div class="user-detail-card" v-if="userInfo &amp;&amp; userInfo?.isAdmin &amp;&amp; user &amp;&amp; user?.username == userInfo?.username">
<p class="paper-page-lede">Personal API tokens for this admin account. Send as <code>Authorization: Bearer &lt;token&gt;</code> to authenticate without GitHub OAuth (useful for development).</p>
<form class="d-flex" style="gap: 8px; margin-bottom: 12px;" v-form="viewState" @submit.prevent="submitForm($event, () =&gt; { createToken() })">
<input type="text" class="form-control" placeholder="Token name (e.g. dev-laptop)" required v-field="{ state: viewState, set: value =&gt; { tokenForm.name = value }, value: tokenForm?.name, form: null, options: {} }">
<button type="submit" class="btn btn-ink"><svg class="ui-icon" aria-hidden="true"><use href="#project-icon-plus"></use></svg> Generate</button>
</form>
<div class="alert alert-warning" role="alert" v-if="tokenForm?.plaintext">
<strong>Copy this token now — it will not be shown again:</strong>
<pre style="white-space: pre-wrap; word-break: break-all; margin: 8px 0 0; font-family: var(--font-mono); font-size: 0.85rem;">{{ tokenForm?.plaintext }}</pre>
<button class="btn btn-sm" @click="tokenForm.plaintext = null">Dismiss</button>
</div>
<div class="paper-table w-100" v-if="tokens?.length">
<div class="paper-table-head" role="row" style="grid-template-columns: 1fr 200px 200px 80px;">
<div role="columnheader">Name</div>
<div role="columnheader">Created</div>
<div role="columnheader">Last used</div>
<div role="columnheader" aria-label="Actions"></div>
</div>
<div class="paper-table-row" role="row" style="grid-template-columns: 1fr 200px 200px 80px;" v-for="(t, index) in tokens">
<div role="cell" v-text="t?.name"></div>
<div role="cell" v-text="fmt?.humanTime(t?.createdAt)"></div>
<div role="cell"><span v-if="t?.lastUsedAt">{{ fmt?.humanTime(t?.lastUsedAt) }}</span><span class="text-muted" v-if="!t?.lastUsedAt">never</span></div>
<div role="cell">
<button class="btn btn-sm text-danger btn-icon" title="Revoke" aria-label="Revoke token" @click="revokeToken(t)"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-trash"></use></svg></button>
</div>
</div>
</div>
<div class="paper-table-empty" v-if="!tokens?.length">
<svg class="ui-icon" aria-hidden="true"><use href="#project-icon-inbox"></use></svg>
<span>No tokens yet.</span>
</div>
</div>
<div class="admin-section-header">
<h2><svg class="ui-icon" aria-hidden="true"><use href="#project-type-repo"></use></svg> Anonymized repositories</h2>
<span class="section-count">{{ repositories?.length }}</span>
</div>
<div class="seg-bar">
<div class="seg-filter" role="radiogroup" aria-label="Filter by status">
<button type="button" role="radio" class="seg-chip" :class="{ active: segView() === 'all' }" :aria-checked="segView() === 'all'" @click="setSegView('all')">All <span class="count">{{ fmt?.number(segCount('all')) }}</span></button>
<button type="button" role="radio" class="seg-chip" :class="{ active: segView() === 'ready', 'is-empty': !segCount('ready') }" :aria-checked="segView() === 'ready'" @click="setSegView('ready')"><span class="seg-dot ok" aria-hidden="true"></span>Ready <span class="count">{{ fmt?.number(segCount('ready')) }}</span></button>
<button type="button" role="radio" class="seg-chip" :class="{ active: segView() === 'preparing', 'is-empty': !segCount('preparing') }" :aria-checked="segView() === 'preparing'" @click="setSegView('preparing')"><span class="seg-dot warn" aria-hidden="true"></span>Preparing <span class="count">{{ fmt?.number(segCount('preparing')) }}</span></button>
<button type="button" role="radio" class="seg-chip" :class="{ active: segView() === 'error', 'is-empty': !segCount('error') }" :aria-checked="segView() === 'error'" @click="setSegView('error')"><span class="seg-dot bad" aria-hidden="true"></span>Error <span class="count">{{ fmt?.number(segCount('error')) }}</span></button>
<button type="button" role="radio" class="seg-chip" :class="{ active: segView() === 'expired', 'is-empty': !segCount('expired') }" :aria-checked="segView() === 'expired'" @click="setSegView('expired')"><span class="seg-dot off" aria-hidden="true"></span>Expiring / expired <span class="count">{{ fmt?.number(segCount('expired')) }}</span></button>
<button type="button" role="radio" class="seg-chip" :class="{ active: segView() === 'removed', 'is-empty': !segCount('removed') }" :aria-checked="segView() === 'removed'" @click="setSegView('removed')"><span class="seg-dot off" aria-hidden="true"></span>Removing / removed <span class="count">{{ fmt?.number(segCount('removed')) }}</span></button>
</div>
<p class="seg-countline" role="status"><strong>{{ fmt.number(filteredRepositories?.length || 0) }}</strong> shown</p>
</div>
<form class="w-100 admin-filter-toolbar" aria-label="Repositories" accept-charset="UTF-8" v-form="viewState">
<div class="admin-filter-row">
<div class="search-wrap">
<input type="search" class="form-control" aria-label="Search repositories" placeholder="Search repositories or errors…" autocomplete="off" v-field="{ state: viewState, set: value =&gt; { search = value }, value: search, form: null, options: {} }">
</div>
<span class="admin-filter-spacer"></span>
<button class="btn btn-sm" type="button" title="Export current view to CSV" @click="exportCsv()"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-export"></use></svg> Export</button>
</div>
</form>
<div class="bulk-bar" v-if="selectedCount() &gt; 0">
<span><strong>{{ selectedCount() }}</strong> selected</span>
<button class="btn btn-sm" type="button" @click="bulkRefresh()"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-refresh"></use></svg> Force refresh</button>
<button class="btn btn-sm text-danger" type="button" @click="bulkRemoveCache()"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-broom"></use></svg> Remove cache</button>
<button class="btn btn-sm" type="button" @click="clearSelection()">Clear</button>
</div>
<div class="paper-table paper-table-repos has-bulk w-100" role="table" aria-label="Repositories">
<div class="paper-table-head" role="row">
<div role="columnheader" style="width: 28px;">
<input type="checkbox" aria-label="Select all on page" @click="selectAllOnPage()" :checked="allSelected">
</div>
<div role="columnheader"><button type="button" class="sortable" :class="{active: query?.sort == &#x27;source.repositoryName&#x27;}" @click="sortBy(&#x27;source.repositoryName&#x27;)">Repository <svg class="ui-icon sort-direction" aria-hidden="true" v-if="sortIcon(&#x27;source.repositoryName&#x27;)" :class="{ascending: sortIcon(&#x27;source.repositoryName&#x27;) === 'fa-arrow-up'}"><use href="#project-icon-arrow"></use></svg></button></div>
<div role="columnheader"><button type="button" class="sortable" :class="{active: query?.sort == &#x27;status&#x27;}" @click="sortBy(&#x27;status&#x27;)">Status <svg class="ui-icon sort-direction" aria-hidden="true" v-if="sortIcon(&#x27;status&#x27;)" :class="{ascending: sortIcon(&#x27;status&#x27;) === 'fa-arrow-up'}"><use href="#project-icon-arrow"></use></svg></button></div>
<div role="columnheader" class="num"><button type="button" class="sortable" :class="{active: query?.sort == &#x27;pageView&#x27;}" @click="sortBy(&#x27;pageView&#x27;)">Views <svg class="ui-icon sort-direction" aria-hidden="true" v-if="sortIcon(&#x27;pageView&#x27;)" :class="{ascending: sortIcon(&#x27;pageView&#x27;) === 'fa-arrow-up'}"><use href="#project-icon-arrow"></use></svg></button></div>
<div role="columnheader"><button type="button" class="sortable" :class="{active: query?.sort == &#x27;anonymizeDate&#x27;}" @click="sortBy(&#x27;anonymizeDate&#x27;)">Anonymized <svg class="ui-icon sort-direction" aria-hidden="true" v-if="sortIcon(&#x27;anonymizeDate&#x27;)" :class="{ascending: sortIcon(&#x27;anonymizeDate&#x27;) === 'fa-arrow-up'}"><use href="#project-icon-arrow"></use></svg></button></div>
<div role="columnheader" aria-label="Actions"></div>
</div>
<div class="paper-table-row" role="row" v-for="(repo, index) in filteredRepositories" :class="{&#x27;repo-inactive&#x27;: repo?.status == &#x27;expired&#x27; || repo?.status == &#x27;removed&#x27;, &#x27;repo-error&#x27;: repo?.status == &#x27;error&#x27;, &#x27;row-selected&#x27;: selected[repo?.repoId]}">
<div role="cell" style="width: 28px;">
<input type="checkbox" aria-label="Select repository" v-field="{ state: viewState, set: value =&gt; { selected[repo.repoId] = value }, value: selected[repo?.repoId], form: null, options: {} }">
</div>
<div class="cell-anon" role="cell">
<svg class="ui-icon record-type-icon" role="img" aria-label="Repository"><use href="#project-type-repo"></use></svg>
<div class="anon-text">
<a class="repo-name" target="_blank" v-text="repo?.repoId" :href="safeUrl(&quot;/r/&quot; + (repo?.repoId))"></a>
<div class="anon-sub anon-meta" data-meta-fit :title="[repo?.source?.fullName || repo?.source?.repositoryName, repo?.options?.update ? 'branch ' + repo?.source?.branch : 'commit ' + repo?.source?.commit?.substring(0, 7), repo?.conference ? 'conference ' + repo?.conference : '', repo?.size?.storage &gt; 0 ? fmt?.humanFileSize(repo?.size?.storage) : '', fmt?.number(repo?.options?.terms?.length) + (repo?.options?.terms?.length === 1 ? ' term' : ' terms')].filter(Boolean).join(' · ')">
<a class="src-path" :href="safeUrl(&quot;https://github.com/&quot; + (repo?.source?.fullName) + &quot;/&quot;)"><span class="src-owner" v-if="(repo?.source?.fullName || '').includes('/')" v-text="(o => o.length > 16 ? o.slice(0, 14) + '…' : o)((repo?.source?.fullName || '').split('/')[0])"></span><span class="src-repo" v-text="(repo?.source?.fullName || '').includes('/') ? '/' + (repo?.source?.fullName || '').split('/').slice(1).join('/') : repo?.source?.fullName"></span></a><span class="meta-item" v-if="!repo?.options?.update" :data-meta-label="'commit ' + (repo?.source?.commit || '').substring(0, 7)"><a class="mono-hash" :title="repo?.source?.commit" v-text="repo?.source?.commit?.substring(0, 7)" :href="safeUrl(&quot;https://github.com/&quot; + (repo?.source?.fullName) + &quot;/tree/&quot; + (repo?.source?.commit))"></a></span><span class="meta-item" v-if="repo?.options?.update" :data-meta-label="'branch ' + (repo?.source?.branch || '')"><a v-text="repo?.source?.branch" :href="safeUrl(&quot;https://github.com/&quot; + (repo?.source?.fullName) + &quot;/tree/&quot; + (repo?.source?.branch))"></a></span><span class="meta-item meta-conf" v-if="repo?.conference" :data-meta-label="'conference ' + repo?.conference"><svg class="ui-icon" aria-hidden="true"><use href="#project-icon-conference"></use></svg> {{ repo?.conference }}</span><span class="meta-item" v-if="repo?.size?.storage &gt; 0">{{ fmt?.humanFileSize(repo?.size?.storage) }}</span><span class="meta-item">{{ fmt?.number(repo?.options?.terms?.length) }} {{ repo?.options?.terms?.length === 1 ? 'term' : 'terms' }}</span><span class="meta-more" hidden></span>
</div>
</div>
</div>
<div class="cell-status" role="cell">
<span class="status-line">
<span class="status-dot" :class="'st-' + (repo?.status || 'unknown')"></span>
<span v-text="fmt?.statusLabel(repo?.status)"></span>
</span>
<span class="status-sub" v-if="repo?.statusMessage" v-text="fmt?.statusMsg(repo?.statusMessage)" :title="(repo?.statusMessage)"></span>
</div>
<div class="cell-views num" role="cell" :data-unit="(repo?.pageView || 0) === 1 ? 'view' : 'views'" v-text="fmt?.number(repo?.pageView || 0)"></div>
<div class="cell-expires" role="cell" v-text="fmt?.humanTime(repo?.anonymizeDate)"></div>
<div class="cell-actions" role="cell">
<div class="dropdown">
<button class="btn btn-icon-dots" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" aria-label="Actions">
<svg class="ui-icon" aria-hidden="true"><use href="#project-icon-more"></use></svg>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item" :href="safeUrl(&quot;/anonymize/&quot; + (repo?.repoId))"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-edit"></use></svg> Edit</a>
<a class="dropdown-item" :href="safeUrl(&quot;/r/&quot; + (repo?.repoId) + &quot;/&quot;)"><svg class="ui-icon" aria-hidden="true"><use href="#project-icon-eye"></use></svg> View repo</a>
<a class="dropdown-item" target="_self" v-if="repo?.options?.page &amp;&amp; repo?.status == &#x27;ready&#x27;" :href="safeUrl(&quot;/w/&quot; + (repo?.repoId) + &quot;/&quot;)"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-globe"></use></svg> View page</a>
<a class="dropdown-item" href="#" @click.prevent="fetchGithubInfo(repo)"><i class="fab fa-github"></i> Live GitHub info</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" v-show="repo?.status == &#x27;ready&#x27; || repo?.status == &#x27;error&#x27;" @click.prevent="updateRepository(repo)"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-refresh"></use></svg> Force update</a>
<a class="dropdown-item" href="#" v-show="repo?.status == &#x27;removed&#x27;" @click.prevent="updateRepository(repo)"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-check-circle"></use></svg> Enable</a>
<a class="dropdown-item" href="#" v-show="repo?.statusMessage" @click.prevent="showStatusMessage(repo)"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-warning"></use></svg> View status message</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" @click.prevent="removeCache(repo)"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-broom"></use></svg> Remove cache</a>
<a class="dropdown-item text-danger" href="#" v-show="repo?.status == &#x27;ready&#x27;" @click.prevent="removeRepository(repo)"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-trash"></use></svg> Remove</a>
</div>
</div>
</div>
</div>
<div class="paper-table-empty" v-if="filteredRepositories?.length == 0">
<svg class="ui-icon" aria-hidden="true"><use href="#project-icon-inbox"></use></svg>
<span>No repositories match the current filters.</span>
</div>
</div>
</div>