Skip to content

Commit 76b4659

Browse files
committed
feat: signup bonus offset — subtract promotional credits from balances
Owner-configurable per-service bonus offset stored in config. Dashboard shows adjusted balances with promo footnote. Summary total computed as sum of clamped per-service adjusted balances with proper USD conversion for non-USD services. Settings gear on every service row for easy access.
1 parent c73a9b9 commit 76b4659

5 files changed

Lines changed: 284 additions & 14 deletions

File tree

app/main.py

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from __future__ import annotations
88

99
import asyncio
10+
import contextlib
1011
import json
1112
import logging
1213
import os
@@ -1040,13 +1041,40 @@ async def api_earnings_summary(request: Request) -> dict[str, Any]:
10401041
_require_auth_api(request)
10411042
summary = await database.get_earnings_dashboard_summary()
10421043

1043-
# Include non-USD balances converted to USD in the total
1044+
# Load config for signup bonus offsets
1045+
all_config = await database.get_config()
1046+
if not isinstance(all_config, dict):
1047+
all_config = {}
1048+
1049+
# Include non-USD balances converted to USD in the total.
1050+
# Compute total_adjusted as the sum of clamped per-service adjusted
1051+
# balances (converted to USD) so it always matches the breakdown view.
10441052
all_earnings = await database.get_earnings_summary()
1053+
total_bonus_usd = 0.0
1054+
total_adjusted = 0.0
10451055
for e in all_earnings:
1046-
if e["currency"] != "USD":
1047-
usd_val = exchange_rates.to_usd(e["balance"], e["currency"])
1056+
slug = e.get("platform", "")
1057+
balance = float(e["balance"])
1058+
currency = e["currency"]
1059+
1060+
bonus = 0.0
1061+
with contextlib.suppress(ValueError, TypeError):
1062+
bonus = float(all_config.get(f"{slug}_signup_bonus", "0") or "0")
1063+
adjusted = max(0.0, balance - bonus)
1064+
1065+
if currency != "USD":
1066+
usd_val = exchange_rates.to_usd(balance, currency)
10481067
if usd_val is not None:
10491068
summary["total"] = round(summary["total"] + usd_val, 2)
1069+
adj_usd = exchange_rates.to_usd(adjusted, currency)
1070+
if adj_usd is not None:
1071+
total_adjusted += adj_usd
1072+
bonus_usd = exchange_rates.to_usd(bonus, currency) if bonus > 0 else 0.0
1073+
if bonus_usd is not None:
1074+
total_bonus_usd += bonus_usd
1075+
else:
1076+
total_adjusted += adjusted
1077+
total_bonus_usd += bonus
10501078

10511079
# Count active (running) services from worker data
10521080
active = 0
@@ -1056,6 +1084,8 @@ async def api_earnings_summary(request: Request) -> dict[str, Any]:
10561084
except Exception:
10571085
pass
10581086
summary["active_services"] = active
1087+
summary["total_bonus"] = round(total_bonus_usd, 2)
1088+
summary["total_adjusted"] = round(total_adjusted, 2)
10591089
return summary
10601090

10611091

@@ -1074,6 +1104,11 @@ async def api_earnings_breakdown(request: Request) -> list[dict[str, Any]]:
10741104
_require_auth_api(request)
10751105
rows = await database.get_earnings_per_service()
10761106

1107+
# Load config for per-service signup bonus offsets
1108+
all_config = await database.get_config()
1109+
if not isinstance(all_config, dict):
1110+
all_config = {}
1111+
10771112
result = []
10781113
for row in rows:
10791114
slug = row["platform"]
@@ -1084,10 +1119,18 @@ async def api_earnings_breakdown(request: Request) -> list[dict[str, Any]]:
10841119
prev_balance = float(row.get("prev_balance", 0))
10851120
delta = balance - prev_balance
10861121

1122+
# Signup bonus offset (stored in config as {slug}_signup_bonus)
1123+
signup_bonus = 0.0
1124+
with contextlib.suppress(ValueError, TypeError):
1125+
signup_bonus = float(all_config.get(f"{slug}_signup_bonus", "0") or "0")
1126+
balance_adjusted = round(max(0.0, balance - signup_bonus), 4)
1127+
10871128
entry = {
10881129
"platform": slug,
10891130
"name": svc["name"] if svc else slug,
10901131
"balance": round(balance, 4),
1132+
"balance_adjusted": balance_adjusted,
1133+
"signup_bonus": round(signup_bonus, 4),
10911134
"currency": row["currency"],
10921135
"last_updated": row["date"],
10931136
"delta": round(delta, 4),
@@ -1323,7 +1366,11 @@ async def api_collectors_meta(request: Request) -> list[dict[str, Any]]:
13231366
"required": not optional,
13241367
}
13251368
)
1326-
entry: dict[str, Any] = {"slug": slug, "name": name, "fields": fields}
1369+
# Payment currency for bonus offset labeling
1370+
payment = (svc.get("payment", {}) if svc else {}) or {}
1371+
pay_currency = payment.get("currency", "USD")
1372+
1373+
entry: dict[str, Any] = {"slug": slug, "name": name, "fields": fields, "currency": pay_currency}
13271374
if slug in hints:
13281375
entry["hint"] = hints[slug]
13291376
meta.append(entry)

app/static/js/app.js

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -194,13 +194,26 @@ const CP = (() => {
194194
async function loadDashboardStats() {
195195
try {
196196
const data = await api('/api/earnings/summary');
197-
setTextContent('total-earnings', formatCurrency(data.total || 0));
197+
const totalBonus = data.total_bonus || 0;
198+
const displayTotal = totalBonus > 0 ? (data.total_adjusted || 0) : (data.total || 0);
199+
setTextContent('total-earnings', formatCurrency(displayTotal));
198200
setTextContent('today-earnings', formatCurrency(data.today || 0));
199201
setTextContent('month-earnings', formatCurrency(data.month || 0));
200202
setTextContent('active-services', data.active_services || 0);
201203

204+
// Show promo offset footnote under total
205+
const bonusNote = document.getElementById('total-bonus-note');
206+
if (bonusNote) {
207+
if (totalBonus > 0) {
208+
bonusNote.textContent = `\u2212${formatCurrency(totalBonus)} promo`;
209+
bonusNote.style.display = '';
210+
} else {
211+
bonusNote.style.display = 'none';
212+
}
213+
}
214+
202215
// Update topbar
203-
setTextContent('topbar-total', formatCurrency(data.total || 0));
216+
setTextContent('topbar-total', formatCurrency(displayTotal));
204217

205218
// Change indicators
206219
if (data.today_change !== undefined) {
@@ -242,8 +255,8 @@ const CP = (() => {
242255
case 'balance': {
243256
const ba = breakdownMap[a.slug];
244257
const bb = breakdownMap[b.slug];
245-
va = (ba && ba.balance) || a.balance || 0;
246-
vb = (bb && bb.balance) || b.balance || 0;
258+
va = (ba && ba.signup_bonus) ? (ba.balance_adjusted ?? ba.balance) : ((ba && ba.balance) || a.balance || 0);
259+
vb = (bb && bb.signup_bonus) ? (bb.balance_adjusted ?? bb.balance) : ((bb && bb.balance) || b.balance || 0);
247260
break;
248261
}
249262
case 'change': {
@@ -411,20 +424,26 @@ const CP = (() => {
411424

412425
// Balance + delta from breakdown
413426
const balance = (bk && bk.balance) || svc.balance || 0;
427+
const signupBonus = (bk && bk.signup_bonus) || 0;
428+
const balanceAdj = signupBonus > 0 ? ((bk && bk.balance_adjusted) ?? Math.max(0, balance - signupBonus)) : balance;
414429
const currency = (bk && bk.currency) || svc.currency || 'USD';
415430
const delta = bk ? bk.delta : 0;
416431
const deltaSign = delta > 0 ? '+' : '';
417432
const deltaClass = delta > 0 ? 'positive' : delta < 0 ? 'negative' : '';
418433
const deltaStr = delta !== 0 ? `${deltaSign}${formatCurrency(delta, currency)}` : '--';
419-
const nativeLabel = formatNative(balance, currency);
434+
const displayBalance = signupBonus > 0 ? balanceAdj : balance;
435+
const nativeLabel = formatNative(displayBalance, currency);
436+
const bonusLabel = signupBonus > 0
437+
? `<div style="font-size:0.6rem; color:var(--text-muted);">\u2212${formatCurrency(signupBonus, currency)} promo</div>`
438+
: '';
420439
const disconnectedLabel = svc.collector_disconnected
421440
? `<div style="font-size:0.6rem; color:#ef4444; font-weight:500; display:flex; align-items:center; justify-content:flex-end; gap:4px;">disconnected${_isOwner ? ` <button class="btn btn-ghost" onclick="event.stopPropagation(); CP.openCredentialModal('${escapeHtml(svc.slug)}')" style="font-size:0.6rem; padding:1px 5px; line-height:1.2; color:#ef4444; border:1px solid #ef4444; border-radius:3px; cursor:pointer;">update</button>` : ''}</div>`
422441
: '';
423442
let balanceHtml;
424443
if (nativeLabel) {
425-
balanceHtml = `${formatCurrency(balance, currency)}<div style="font-size:0.65rem;color:var(--text-muted);">${nativeLabel}</div>${disconnectedLabel}`;
444+
balanceHtml = `${formatCurrency(displayBalance, currency)}<div style="font-size:0.65rem;color:var(--text-muted);">${nativeLabel}</div>${bonusLabel}${disconnectedLabel}`;
426445
} else {
427-
balanceHtml = `${formatCurrency(balance, currency)}${disconnectedLabel}`;
446+
balanceHtml = `${formatCurrency(displayBalance, currency)}${bonusLabel}${disconnectedLabel}`;
428447
}
429448

430449
// CPU/Memory — skip for external; show avg for multi-instance
@@ -469,16 +488,22 @@ const CP = (() => {
469488
? ` <span class="badge badge-instances" title="${instances} instance${instances > 1 ? 's' : ''}">${instances}x</span>`
470489
: '';
471490

491+
// Settings gear (owner-only) — opens credential + bonus modal
492+
const settingsBtn = _isOwner
493+
? `<button class="btn btn-icon" onclick="event.stopPropagation(); CP.openCredentialModal('${escapeHtml(svc.slug)}')" title="Credentials &amp; settings">
494+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
495+
</button>` : '';
496+
472497
// For multi-instance: expand chevron, no container action buttons in main row
473498
// For single instance: show action buttons directly
474499
let actionBtns;
475500
if (isMulti) {
476501
const chevron = `<button class="btn btn-icon expand-toggle" onclick="event.stopPropagation(); CP.toggleInstances('${svc.slug}')" title="Expand instances">
477502
<svg class="expand-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
478503
</button>`;
479-
actionBtns = `<div class="action-btns">${claimBtn}${chevron}</div>`;
504+
actionBtns = `<div class="action-btns">${claimBtn}${settingsBtn}${chevron}</div>`;
480505
} else if (isExternal) {
481-
actionBtns = `<div class="action-btns">${claimBtn}</div>`;
506+
actionBtns = `<div class="action-btns">${claimBtn}${settingsBtn}</div>`;
482507
} else {
483508
// Single instance — build container buttons targeting the right node
484509
const inst = details[0] || {};
@@ -487,6 +512,7 @@ const CP = (() => {
487512
const disabledAttr = noDocker ? ' disabled title="No Docker access"' : '';
488513
actionBtns = `<div class="action-btns">
489514
${claimBtn}
515+
${settingsBtn}
490516
${_canWrite ? `
491517
<button class="btn btn-icon" onclick="CP.restartService('${svc.slug}${wParam})" title="Restart"${disabledAttr}>
492518
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
@@ -716,9 +742,28 @@ const CP = (() => {
716742
}).join('');
717743
const hint = col.hint || '';
718744

745+
// Signup bonus offset field — show current value from config
746+
const bonusKey = `${slug}_signup_bonus`;
747+
const currentBonus = config[bonusKey] || '';
748+
const payCurrency = col.currency || 'USD';
749+
const currencyLabel = payCurrency === 'USD' ? '$' : payCurrency;
750+
const bonusHtml = `
751+
<div style="margin-top:14px; padding-top:12px; border-top:1px solid var(--border);">
752+
<label style="display:block; font-size:0.8rem; color:var(--text-secondary); margin-bottom:4px;">Signup Bonus Offset (${escapeHtml(currencyLabel)})</label>
753+
<div style="display:flex; align-items:center; gap:6px;">
754+
<input class="form-input cred-modal-input" type="number" step="0.01" min="0"
755+
data-config="${escapeHtml(bonusKey)}"
756+
value="${escapeHtml(currentBonus)}"
757+
placeholder="0.00"
758+
style="width:100px;">
759+
<span style="font-size:0.75rem; color:var(--text-muted);">Subtract promotional credits from displayed balance</span>
760+
</div>
761+
</div>`;
762+
719763
body.innerHTML = `
720764
${hint ? `<p style="font-size:0.75rem; color:var(--text-muted); margin:0 0 12px; line-height:1.4;">${hint}</p>` : ''}
721765
${fieldsHtml}
766+
${bonusHtml}
722767
<div style="display:flex; gap:8px; margin-top:14px;">
723768
<button class="btn btn-primary btn-sm" onclick="CP.saveCredentialModal()">Save</button>
724769
<button class="btn btn-ghost btn-sm" onclick="CP.closeModal('cred-modal')">Cancel</button>

app/templates/dashboard.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<div class="stat-card">
1111
<div class="stat-label">Total Balance</div>
1212
<div class="stat-value highlight" id="total-earnings">$0.00</div>
13+
<div id="total-bonus-note" style="display:none; font-size:0.7rem; color:var(--text-muted); margin-top:2px;"></div>
1314
</div>
1415
<div class="stat-card">
1516
<div class="stat-label">Today</div>

tests/test_eligibility.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,13 @@ def _service(slug, cashout=None):
4040
return svc
4141

4242

43-
def _call_breakdown(rows, services_by_slug):
43+
def _call_breakdown(rows, services_by_slug, config=None):
4444
"""Call the real handler with mocked dependencies."""
4545
request = MagicMock()
4646
with (
4747
patch("app.main.auth.get_current_user", return_value={"uid": 1, "u": "test", "r": "owner"}),
4848
patch("app.main.database.get_earnings_per_service", new_callable=AsyncMock, return_value=rows),
49+
patch("app.main.database.get_config", new_callable=AsyncMock, return_value=config or {}),
4950
patch("app.main.catalog.get_service", side_effect=lambda slug: services_by_slug.get(slug)),
5051
):
5152
return asyncio.run(api_earnings_breakdown(request))
@@ -142,3 +143,63 @@ def test_edge_cases(self, balance, min_amount, expected):
142143
svcs = {"svc-a": _service("svc-a", cashout={"min_amount": min_amount, "dashboard_url": "https://x.com"})}
143144
result = _call_breakdown(rows, svcs)
144145
assert result[0]["cashout"]["eligible"] is expected
146+
147+
148+
class TestSignupBonusOffset:
149+
"""Signup bonus offset subtracts promotional credits from displayed balance."""
150+
151+
def test_no_bonus_returns_same_balance(self):
152+
rows = [_earnings_row("svc-a", balance=10.0)]
153+
svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 5})}
154+
result = _call_breakdown(rows, svcs)
155+
assert result[0]["balance"] == 10.0
156+
assert result[0]["balance_adjusted"] == 10.0
157+
assert result[0]["signup_bonus"] == 0.0
158+
159+
def test_bonus_subtracts_from_adjusted(self):
160+
rows = [_earnings_row("svc-a", balance=15.0)]
161+
svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 5})}
162+
config = {"svc-a_signup_bonus": "5.0"}
163+
result = _call_breakdown(rows, svcs, config=config)
164+
assert result[0]["balance"] == 15.0
165+
assert result[0]["balance_adjusted"] == 10.0
166+
assert result[0]["signup_bonus"] == 5.0
167+
168+
def test_bonus_never_goes_negative(self):
169+
rows = [_earnings_row("svc-a", balance=3.0)]
170+
svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 5})}
171+
config = {"svc-a_signup_bonus": "10.0"}
172+
result = _call_breakdown(rows, svcs, config=config)
173+
assert result[0]["balance"] == 3.0
174+
assert result[0]["balance_adjusted"] == 0.0
175+
176+
def test_eligibility_uses_raw_balance(self):
177+
"""Cashout eligibility should use raw platform balance, not adjusted."""
178+
rows = [_earnings_row("svc-a", balance=10.0)]
179+
svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 5, "dashboard_url": "https://x.com"})}
180+
config = {"svc-a_signup_bonus": "8.0"}
181+
result = _call_breakdown(rows, svcs, config=config)
182+
assert result[0]["balance_adjusted"] == 2.0
183+
assert result[0]["cashout"]["eligible"] is True # raw 10 >= min 5
184+
185+
def test_invalid_bonus_treated_as_zero(self):
186+
rows = [_earnings_row("svc-a", balance=10.0)]
187+
svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 5})}
188+
config = {"svc-a_signup_bonus": "not-a-number"}
189+
result = _call_breakdown(rows, svcs, config=config)
190+
assert result[0]["balance_adjusted"] == 10.0
191+
assert result[0]["signup_bonus"] == 0.0
192+
193+
def test_multiple_services_with_bonuses(self):
194+
rows = [
195+
_earnings_row("svc-a", balance=20.0),
196+
_earnings_row("svc-b", balance=8.0),
197+
]
198+
svcs = {
199+
"svc-a": _service("svc-a", cashout={"min_amount": 0}),
200+
"svc-b": _service("svc-b", cashout={"min_amount": 0}),
201+
}
202+
config = {"svc-a_signup_bonus": "5.0", "svc-b_signup_bonus": "3.0"}
203+
result = _call_breakdown(rows, svcs, config=config)
204+
assert result[0]["balance_adjusted"] == 15.0
205+
assert result[1]["balance_adjusted"] == 5.0

0 commit comments

Comments
 (0)