Spaces:
Running
Running
AuthorBot Cursor commited on
Commit Β·
cf196a9
1
Parent(s): a825fee
Fix token display for unsubscribed authors and add SuperAdmin author removal.
Browse filesToken usage now reads only active grants; admin shows a no-subscription banner. SuperAdmin can permanently delete author accounts with confirmation.
Co-authored-by: Cursor <cursoragent@cursor.com>
- app/admin/router.py +6 -16
- app/admin/templates/admin.html +23 -9
- app/services/superadmin_service.py +50 -0
- app/services/token_budget.py +23 -0
- app/superadmin/router.py +16 -0
- app/superadmin/templates/superadmin.html +29 -1
- tests/unit/test_suite_a_token_consistency.py +13 -0
app/admin/router.py
CHANGED
|
@@ -756,23 +756,13 @@ async def get_token_usage(
|
|
| 756 |
current_user=Depends(get_current_user),
|
| 757 |
db: AsyncSession = Depends(get_db),
|
| 758 |
):
|
| 759 |
-
"""Return token budget and consumption."""
|
| 760 |
-
from
|
| 761 |
-
from app.
|
| 762 |
-
|
| 763 |
-
result = await db.execute(
|
| 764 |
-
select(ClientAccess)
|
| 765 |
-
.where(ClientAccess.author_id == current_user.id)
|
| 766 |
-
.order_by(ClientAccess.granted_at.desc())
|
| 767 |
-
.limit(1)
|
| 768 |
-
)
|
| 769 |
-
access = result.scalar_one_or_none()
|
| 770 |
-
if not access:
|
| 771 |
-
return {"budget": 0, "used": 0, "remaining": 0, "percent_used": 0}
|
| 772 |
-
|
| 773 |
-
from app.services.token_budget import usage_summary
|
| 774 |
|
| 775 |
-
|
|
|
|
|
|
|
| 776 |
|
| 777 |
|
| 778 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 756 |
current_user=Depends(get_current_user),
|
| 757 |
db: AsyncSession = Depends(get_db),
|
| 758 |
):
|
| 759 |
+
"""Return token budget and consumption for the active subscription only."""
|
| 760 |
+
from app.repositories.access_repo import AccessRepository
|
| 761 |
+
from app.services.token_budget import usage_summary_or_empty
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 762 |
|
| 763 |
+
access_repo = AccessRepository(db)
|
| 764 |
+
access = await access_repo.get_active_for_author(current_user.id)
|
| 765 |
+
return usage_summary_or_empty(access)
|
| 766 |
|
| 767 |
|
| 768 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
app/admin/templates/admin.html
CHANGED
|
@@ -535,6 +535,9 @@
|
|
| 535 |
<!-- βββ TOKEN USAGE βββ -->
|
| 536 |
<div class="page" id="page-tokens">
|
| 537 |
<div class="page-header"><h1 class="page-title">Token Usage <span class="info-tip">βΉ<span class="tip-text">Tracks your AI token consumption. Each chatbot response uses tokens from your budget. When your budget is exhausted, the chatbot will show a friendly "recharging" message until tokens are replenished.</span></span></h1><p class="page-sub">Monitor your AI token budget and consumption</p></div>
|
|
|
|
|
|
|
|
|
|
| 538 |
<div class="grid-2">
|
| 539 |
<div class="card" style="text-align:center;padding:40px;">
|
| 540 |
<div style="position:relative;width:180px;height:180px;margin:0 auto 20px;">
|
|
@@ -1168,14 +1171,23 @@ async function loadAnalytics(days = 30) {
|
|
| 1168 |
async function loadTokenUsage() {
|
| 1169 |
try {
|
| 1170 |
const data = await apiGet(`/admin/${authorSlug}/token-usage`);
|
| 1171 |
-
const
|
| 1172 |
-
const
|
| 1173 |
-
|
| 1174 |
-
|
| 1175 |
-
|
| 1176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1177 |
document.getElementById('token-used').textContent = used.toLocaleString();
|
| 1178 |
-
document.getElementById('token-remaining').textContent = remaining.toLocaleString();
|
| 1179 |
// Gauge
|
| 1180 |
const canvas = document.getElementById('chart-token-gauge');
|
| 1181 |
if (canvas) {
|
|
@@ -1183,8 +1195,10 @@ async function loadTokenUsage() {
|
|
| 1183 |
const cx = 90, cy = 90, r = 75, lw = 12;
|
| 1184 |
ctx.clearRect(0, 0, 180, 180);
|
| 1185 |
ctx.beginPath(); ctx.arc(cx, cy, r, 0.75 * Math.PI, 2.25 * Math.PI); ctx.strokeStyle = '#f1f3f9'; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.stroke();
|
| 1186 |
-
|
| 1187 |
-
|
|
|
|
|
|
|
| 1188 |
}
|
| 1189 |
} catch(e) { console.error('Token usage:', e); }
|
| 1190 |
}
|
|
|
|
| 535 |
<!-- βββ TOKEN USAGE βββ -->
|
| 536 |
<div class="page" id="page-tokens">
|
| 537 |
<div class="page-header"><h1 class="page-title">Token Usage <span class="info-tip">βΉ<span class="tip-text">Tracks your AI token consumption. Each chatbot response uses tokens from your budget. When your budget is exhausted, the chatbot will show a friendly "recharging" message until tokens are replenished.</span></span></h1><p class="page-sub">Monitor your AI token budget and consumption</p></div>
|
| 538 |
+
<div id="token-subscription-banner" class="hidden" style="margin-bottom:16px;padding:14px 18px;border-radius:10px;background:var(--yellow-bg);border:1px solid var(--yellow);font-size:13px;color:var(--text2);">
|
| 539 |
+
β οΈ <strong>No active subscription.</strong> Contact your platform administrator to get a package before using the chatbot.
|
| 540 |
+
</div>
|
| 541 |
<div class="grid-2">
|
| 542 |
<div class="card" style="text-align:center;padding:40px;">
|
| 543 |
<div style="position:relative;width:180px;height:180px;margin:0 auto 20px;">
|
|
|
|
| 1171 |
async function loadTokenUsage() {
|
| 1172 |
try {
|
| 1173 |
const data = await apiGet(`/admin/${authorSlug}/token-usage`);
|
| 1174 |
+
const banner = document.getElementById('token-subscription-banner');
|
| 1175 |
+
const hasSub = data.has_active_subscription !== false && data.subscription_status === 'active';
|
| 1176 |
+
|
| 1177 |
+
if (banner) {
|
| 1178 |
+
if (hasSub) banner.classList.add('hidden');
|
| 1179 |
+
else banner.classList.remove('hidden');
|
| 1180 |
+
}
|
| 1181 |
+
|
| 1182 |
+
const budget = hasSub ? (data.budget || 0) : 0;
|
| 1183 |
+
const used = hasSub ? (data.used || 0) : 0;
|
| 1184 |
+
const remaining = hasSub ? (data.remaining || 0) : 0;
|
| 1185 |
+
const pct = hasSub ? (data.percent_used || 0) : 0;
|
| 1186 |
+
|
| 1187 |
+
document.getElementById('token-pct').textContent = hasSub ? (pct + '%') : 'β';
|
| 1188 |
+
document.getElementById('token-budget').textContent = hasSub ? budget.toLocaleString() : 'No plan';
|
| 1189 |
document.getElementById('token-used').textContent = used.toLocaleString();
|
| 1190 |
+
document.getElementById('token-remaining').textContent = hasSub ? remaining.toLocaleString() : 'β';
|
| 1191 |
// Gauge
|
| 1192 |
const canvas = document.getElementById('chart-token-gauge');
|
| 1193 |
if (canvas) {
|
|
|
|
| 1195 |
const cx = 90, cy = 90, r = 75, lw = 12;
|
| 1196 |
ctx.clearRect(0, 0, 180, 180);
|
| 1197 |
ctx.beginPath(); ctx.arc(cx, cy, r, 0.75 * Math.PI, 2.25 * Math.PI); ctx.strokeStyle = '#f1f3f9'; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.stroke();
|
| 1198 |
+
if (hasSub && pct > 0) {
|
| 1199 |
+
ctx.beginPath(); ctx.arc(cx, cy, r, 0.75 * Math.PI, (0.75 + 1.5 * pct / 100) * Math.PI);
|
| 1200 |
+
ctx.strokeStyle = pct > 90 ? '#ef4444' : pct > 75 ? '#f59e0b' : '#6366f1'; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.stroke();
|
| 1201 |
+
}
|
| 1202 |
}
|
| 1203 |
} catch(e) { console.error('Token usage:', e); }
|
| 1204 |
}
|
app/services/superadmin_service.py
CHANGED
|
@@ -132,6 +132,56 @@ class SuperAdminService:
|
|
| 132 |
)
|
| 133 |
logger.info("Author unsuspended", author_id=author_id)
|
| 134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
async def grant_access(
|
| 136 |
self,
|
| 137 |
actor: User,
|
|
|
|
| 132 |
)
|
| 133 |
logger.info("Author unsuspended", author_id=author_id)
|
| 134 |
|
| 135 |
+
async def delete_author(self, actor: User, author_id: str) -> None:
|
| 136 |
+
"""Permanently delete an author account and all associated data.
|
| 137 |
+
|
| 138 |
+
Args:
|
| 139 |
+
actor: The SuperAdmin performing the action.
|
| 140 |
+
author_id: UUID of the author to delete.
|
| 141 |
+
|
| 142 |
+
Raises:
|
| 143 |
+
ValueError: If author not found, is SuperAdmin, or is the actor.
|
| 144 |
+
"""
|
| 145 |
+
author = await self._user_repo.get_by_id(author_id)
|
| 146 |
+
if not author:
|
| 147 |
+
raise ValueError(f"Author {author_id} not found")
|
| 148 |
+
if author.role == "superadmin":
|
| 149 |
+
raise ValueError("Cannot delete SuperAdmin accounts")
|
| 150 |
+
if author.id == actor.id:
|
| 151 |
+
raise ValueError("Cannot delete your own account")
|
| 152 |
+
|
| 153 |
+
from sqlalchemy import select
|
| 154 |
+
|
| 155 |
+
result = await self._db.execute(
|
| 156 |
+
select(ClientAccess).where(ClientAccess.author_id == author_id)
|
| 157 |
+
)
|
| 158 |
+
for access in result.scalars().all():
|
| 159 |
+
if not access.is_revoked:
|
| 160 |
+
await revoke_token_in_redis(
|
| 161 |
+
redis=self._redis,
|
| 162 |
+
token_hash=access.token_hash,
|
| 163 |
+
expires_at=access.expires_at,
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
await clear_token_usage_counters(self._redis, author_id)
|
| 167 |
+
|
| 168 |
+
email = author.email
|
| 169 |
+
name = author.full_name or email
|
| 170 |
+
|
| 171 |
+
await self._audit.log(
|
| 172 |
+
actor_id=actor.id,
|
| 173 |
+
actor_email=actor.email,
|
| 174 |
+
action="delete_author",
|
| 175 |
+
target_type="author",
|
| 176 |
+
target_id=author_id,
|
| 177 |
+
details={"email": email, "full_name": name},
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
await self._db.delete(author)
|
| 181 |
+
await self._db.flush()
|
| 182 |
+
|
| 183 |
+
logger.info("Author deleted", author_id=author_id, email=email)
|
| 184 |
+
|
| 185 |
async def grant_access(
|
| 186 |
self,
|
| 187 |
actor: User,
|
app/services/token_budget.py
CHANGED
|
@@ -36,9 +36,32 @@ def usage_summary(access: ClientAccess) -> dict:
|
|
| 36 |
"used": used,
|
| 37 |
"remaining": max(0, budget - used),
|
| 38 |
"percent_used": percent_used(access),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
}
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
async def record_token_usage(
|
| 43 |
db,
|
| 44 |
redis,
|
|
|
|
| 36 |
"used": used,
|
| 37 |
"remaining": max(0, budget - used),
|
| 38 |
"percent_used": percent_used(access),
|
| 39 |
+
"has_active_subscription": True,
|
| 40 |
+
"subscription_status": "active",
|
| 41 |
+
"plan": access.plan,
|
| 42 |
+
"expires_at": access.expires_at.isoformat() if access.expires_at else None,
|
| 43 |
}
|
| 44 |
|
| 45 |
|
| 46 |
+
NO_SUBSCRIPTION_SUMMARY: dict = {
|
| 47 |
+
"budget": 0,
|
| 48 |
+
"used": 0,
|
| 49 |
+
"remaining": 0,
|
| 50 |
+
"percent_used": 0,
|
| 51 |
+
"has_active_subscription": False,
|
| 52 |
+
"subscription_status": "none",
|
| 53 |
+
"plan": None,
|
| 54 |
+
"expires_at": None,
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def usage_summary_or_empty(access: ClientAccess | None) -> dict:
|
| 59 |
+
"""Return usage stats for an active grant, or zeros when unsubscribed."""
|
| 60 |
+
if not access:
|
| 61 |
+
return dict(NO_SUBSCRIPTION_SUMMARY)
|
| 62 |
+
return usage_summary(access)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
async def record_token_usage(
|
| 66 |
db,
|
| 67 |
redis,
|
app/superadmin/router.py
CHANGED
|
@@ -214,6 +214,22 @@ async def unsuspend_author(
|
|
| 214 |
return _err(e, "unsuspend_author")
|
| 215 |
|
| 216 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
# ββ Grant Access ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 218 |
|
| 219 |
@router.post("/authors/{author_id}/grant", status_code=201)
|
|
|
|
| 214 |
return _err(e, "unsuspend_author")
|
| 215 |
|
| 216 |
|
| 217 |
+
@router.delete("/authors/{author_id}", status_code=200)
|
| 218 |
+
async def delete_author(
|
| 219 |
+
author_id: str,
|
| 220 |
+
superadmin=Depends(get_current_superadmin),
|
| 221 |
+
db: AsyncSession = Depends(get_db),
|
| 222 |
+
redis=Depends(get_redis),
|
| 223 |
+
):
|
| 224 |
+
"""Permanently delete an author account and all associated data."""
|
| 225 |
+
try:
|
| 226 |
+
service = SuperAdminService(db, redis)
|
| 227 |
+
await service.delete_author(actor=superadmin, author_id=author_id)
|
| 228 |
+
return {"message": "Author deleted"}
|
| 229 |
+
except Exception as e:
|
| 230 |
+
return _err(e, "delete_author")
|
| 231 |
+
|
| 232 |
+
|
| 233 |
# ββ Grant Access ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 234 |
|
| 235 |
@router.post("/authors/{author_id}/grant", status_code=201)
|
app/superadmin/templates/superadmin.html
CHANGED
|
@@ -249,7 +249,7 @@
|
|
| 249 |
|
| 250 |
<!-- AUTHORS -->
|
| 251 |
<div class="page" id="page-authors">
|
| 252 |
-
<div class="page-header"><h1 class="page-title">Author Management <span class="info-tip">βΉ<span class="tip-text">All registered authors on the platform.
|
| 253 |
<div class="toolbar">
|
| 254 |
<div class="search-bar"><span>π</span><input type="text" placeholder="Search authors..." id="author-search" oninput="filterAuthors()"></div>
|
| 255 |
<div class="toolbar-spacer"></div>
|
|
@@ -515,6 +515,11 @@ async function apiPost(path, body) {
|
|
| 515 |
token = auth.getAccessToken();
|
| 516 |
return data;
|
| 517 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
|
| 519 |
function animateNumber(id, target) {
|
| 520 |
const el = document.getElementById(id); if (!el) return;
|
|
@@ -561,6 +566,7 @@ function renderAuthors(list) {
|
|
| 561 |
? `<button class="btn btn-success btn-sm" onclick="toggleSuspend('${a.id}', false)">Unsuspend</button>`
|
| 562 |
: `<button class="btn btn-danger btn-sm" onclick="toggleSuspend('${a.id}', true)">Suspend</button>`}
|
| 563 |
<button class="btn btn-secondary btn-sm" onclick="viewAuthorDetail('${a.id}')">Details</button>
|
|
|
|
| 564 |
</td>
|
| 565 |
</tr>`).join('');
|
| 566 |
}
|
|
@@ -582,6 +588,28 @@ async function executeSuspend(id) {
|
|
| 582 |
try { await apiPost(`/super/authors/${id}/suspend?reason=${encodeURIComponent(reason)}`, {}); closeModal(); toast('Author suspended', 'success'); loadAuthors(); } catch(e) { toast(e.message, 'error'); }
|
| 583 |
}
|
| 584 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 585 |
function viewAuthorDetail(id) {
|
| 586 |
const a = allAuthors.find(x => x.id === id);
|
| 587 |
if (!a) return;
|
|
|
|
| 249 |
|
| 250 |
<!-- AUTHORS -->
|
| 251 |
<div class="page" id="page-authors">
|
| 252 |
+
<div class="page-header"><h1 class="page-title">Author Management <span class="info-tip">βΉ<span class="tip-text">All registered authors on the platform. Suspend, unsuspend, or permanently remove accounts. Deleting an author removes all books, chats, and subscription data.</span></span></h1><p class="page-sub">Manage all registered author accounts</p></div>
|
| 253 |
<div class="toolbar">
|
| 254 |
<div class="search-bar"><span>π</span><input type="text" placeholder="Search authors..." id="author-search" oninput="filterAuthors()"></div>
|
| 255 |
<div class="toolbar-spacer"></div>
|
|
|
|
| 515 |
token = auth.getAccessToken();
|
| 516 |
return data;
|
| 517 |
}
|
| 518 |
+
async function apiDelete(path) {
|
| 519 |
+
const data = await auth.delete(path);
|
| 520 |
+
token = auth.getAccessToken();
|
| 521 |
+
return data;
|
| 522 |
+
}
|
| 523 |
|
| 524 |
function animateNumber(id, target) {
|
| 525 |
const el = document.getElementById(id); if (!el) return;
|
|
|
|
| 566 |
? `<button class="btn btn-success btn-sm" onclick="toggleSuspend('${a.id}', false)">Unsuspend</button>`
|
| 567 |
: `<button class="btn btn-danger btn-sm" onclick="toggleSuspend('${a.id}', true)">Suspend</button>`}
|
| 568 |
<button class="btn btn-secondary btn-sm" onclick="viewAuthorDetail('${a.id}')">Details</button>
|
| 569 |
+
<button class="btn btn-danger btn-sm" onclick="confirmDeleteAuthor('${a.id}','${esc(a.email)}')" title="Permanently delete this author">Remove</button>
|
| 570 |
</td>
|
| 571 |
</tr>`).join('');
|
| 572 |
}
|
|
|
|
| 588 |
try { await apiPost(`/super/authors/${id}/suspend?reason=${encodeURIComponent(reason)}`, {}); closeModal(); toast('Author suspended', 'success'); loadAuthors(); } catch(e) { toast(e.message, 'error'); }
|
| 589 |
}
|
| 590 |
|
| 591 |
+
function confirmDeleteAuthor(id, email) {
|
| 592 |
+
showModal('Remove Author',
|
| 593 |
+
`<div class="modal-sub">Permanently delete <strong>${email}</strong>? This removes all books, chat history, embeddings, and subscriptions. <strong>This cannot be undone.</strong></div>
|
| 594 |
+
<div class="form-group"><label class="form-label">Type DELETE to confirm</label><input type="text" class="form-input" id="delete-author-confirm" placeholder="DELETE"></div>`,
|
| 595 |
+
`<button class="btn btn-secondary" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="executeDeleteAuthor('${id}')">Remove Author</button>`);
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
async function executeDeleteAuthor(id) {
|
| 599 |
+
const input = document.getElementById('delete-author-confirm');
|
| 600 |
+
if (!input || input.value.trim().toUpperCase() !== 'DELETE') {
|
| 601 |
+
toast('Type DELETE to confirm removal', 'error');
|
| 602 |
+
return;
|
| 603 |
+
}
|
| 604 |
+
try {
|
| 605 |
+
await apiDelete(`/super/authors/${id}`);
|
| 606 |
+
closeModal();
|
| 607 |
+
toast('Author removed', 'success');
|
| 608 |
+
loadAuthors();
|
| 609 |
+
if (typeof loadOverview === 'function') loadOverview();
|
| 610 |
+
} catch(e) { toast(e.message, 'error'); }
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
function viewAuthorDetail(id) {
|
| 614 |
const a = allAuthors.find(x => x.id === id);
|
| 615 |
if (!a) return;
|
tests/unit/test_suite_a_token_consistency.py
CHANGED
|
@@ -76,3 +76,16 @@ def test_superadmin_and_admin_budget_formula_match(client_access):
|
|
| 76 |
super_total = total_budget(client_access)
|
| 77 |
admin_budget = usage_summary(client_access)["budget"]
|
| 78 |
assert admin_budget == super_total
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
super_total = total_budget(client_access)
|
| 77 |
admin_budget = usage_summary(client_access)["budget"]
|
| 78 |
assert admin_budget == super_total
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@pytest.mark.p0
|
| 82 |
+
def test_no_active_subscription_returns_zeros():
|
| 83 |
+
"""Authors without an active grant must see zero budget, not stale grants."""
|
| 84 |
+
from app.services.token_budget import usage_summary_or_empty
|
| 85 |
+
|
| 86 |
+
result = usage_summary_or_empty(None)
|
| 87 |
+
assert result["budget"] == 0
|
| 88 |
+
assert result["used"] == 0
|
| 89 |
+
assert result["remaining"] == 0
|
| 90 |
+
assert result["has_active_subscription"] is False
|
| 91 |
+
assert result["subscription_status"] == "none"
|