Spaces:
Running
Running
unknown Claude Opus 5.5 commited on
Commit ·
662c98f
1
Parent(s): c7ef1c5
Pricing for profit on every run: price = 5 x cost after GST and fees (was 4 x), at least Rs 49 (was Rs 10); a document lowered but not to 0% pays half (was free; half is still about 2.5 x cost), one not lowered stays free; Turnitin's set price never below cost + 30%; free tier trimmed (5 pastes a day, trial document up to 1,500 words, guests 3 pastes); Pricing page states the half-price rule; Admin -> Profit tab: net profit after GST, fees, language model, reports and the always-on GPU bill, cost of free use, paid runs needed to cover the fixed bills, runs below cost, profit by tool (7 / 30 / 90 days)
Browse files- app.py +33 -6
- plans.py +9 -7
- tests/test_app_rephrase_anyway.py +14 -6
- tests/test_dashboard.py +1 -1
- tests/test_pricing.py +4 -4
- tests/test_profit_rules.py +62 -0
- tests/test_result_email.py +1 -1
- wallet.py +77 -7
app.py
CHANGED
|
@@ -211,6 +211,7 @@ def already_human_html(text_mode):
|
|
| 211 |
# Set for a job of a signed-in customer without credits: the job runs, the scores are shown, and the result is
|
| 212 |
# locked behind a charge priced from what the job used.
|
| 213 |
PAYG_FOR = __import__("contextvars").ContextVar("pay_per_use_for", default=None)
|
|
|
|
| 214 |
LOCKED_PREVIEW = ('<div class="cs-scores cs-empty"><div>🔒 The text is shown after payment. Your scores are above.'
|
| 215 |
'</div></div>')
|
| 216 |
|
|
@@ -506,7 +507,8 @@ def _start_billed_job(key, core, args, user, request, tool, need_login, words, q
|
|
| 506 |
meta.get("external_usd", 0), words, meta.get("name") or tool)["charged"]
|
| 507 |
j.extra["wallet"] = (charged, W.available(who))
|
| 508 |
else:
|
| 509 |
-
charged = W.settle(wallet["ref"], time.time() - t0, llm, words, meta.get("name") or tool
|
|
|
|
| 510 |
j.extra["wallet"] = (charged, W.available(who))
|
| 511 |
if paid_order: # paid on the page (PhonePe): the order is used up
|
| 512 |
charged = W.finish_report_order(paid_order, int(meta.get("units_ok", 1)))
|
|
@@ -642,7 +644,8 @@ def _smart_result(res, hi, mod, text_mode, rep=None, name=""):
|
|
| 642 |
msg += (f"\n\n🔒 {locked} flagged sentence(s) could not be edited (citation fields, links, equations or "
|
| 643 |
"sub/superscripts inside the sentence) and keep their wording.")
|
| 644 |
# 0% is the promise: a document that did not reach it is delivered with the sentences still flagged highlighted,
|
| 645 |
-
#
|
|
|
|
| 646 |
short = bool(res.get("zero_goal")) and a["pct"] > 0 and not res.get("report") and not stuck
|
| 647 |
if short:
|
| 648 |
msg = (f"⚠️ **AI score {b['pct']}% → {a['pct']}%.** {res.get('left') or 'A few'} sentence(s) still read as "
|
|
@@ -696,10 +699,12 @@ def _smart_result(res, hi, mod, text_mode, rep=None, name=""):
|
|
| 696 |
out[0] = H.status_done(msg + "\n\n🎁 **No charge for this run**, because the score was not lowered.")
|
| 697 |
if rep is not None and hasattr(rep, "meta"): # given away, not bought: it earns no free re-runs
|
| 698 |
rep.meta.update(no_charge=True)
|
| 699 |
-
if short
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
|
|
|
|
|
|
| 703 |
if charge: # pay per use: scores now, the document after payment
|
| 704 |
out = [H.status_done(msg) + pay_popup_html(charge, "smart"), LOCKED_PREVIEW, panel, None, ""]
|
| 705 |
if text_mode:
|
|
@@ -2923,6 +2928,21 @@ with gr.Blocks(title=SITE_TITLE, theme=theme, css=CSS + UI.css() + FND.CSS, js=F
|
|
| 2923 |
gr.HTML('<div class="cs-settings-title">🏆 Most active users (30 days)</div>')
|
| 2924 |
ad_ov_top = gr.Dataframe(headers=["Name", "Email", "Runs", "Documents", "Words", "Last run"],
|
| 2925 |
interactive=False, wrap=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2926 |
# ---------- activity ----------
|
| 2927 |
with gr.Tab("Activity") as ad_act_tab:
|
| 2928 |
with gr.Row():
|
|
@@ -3136,6 +3156,13 @@ with gr.Blocks(title=SITE_TITLE, theme=theme, css=CSS + UI.css() + FND.CSS, js=F
|
|
| 3136 |
tiles, chart, tools, top = DB.admin_overview(u)
|
| 3137 |
return DB._tiles(P.admin_revenue_tiles(u)) + tiles, chart, tools, top
|
| 3138 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3139 |
ad_ov_refresh.click(overview, user_state, ov_out, **HID)
|
| 3140 |
ad_ov_tab.select(overview, user_state, ov_out, **HID)
|
| 3141 |
admin_tab.select(overview, user_state, ov_out, **HID)
|
|
|
|
| 211 |
# Set for a job of a signed-in customer without credits: the job runs, the scores are shown, and the result is
|
| 212 |
# locked behind a charge priced from what the job used.
|
| 213 |
PAYG_FOR = __import__("contextvars").ContextVar("pay_per_use_for", default=None)
|
| 214 |
+
SHORT_PRICE_FACTOR = float(os.getenv("SHORT_PRICE_FACTOR", "0.5")) # lowered, but not to 0%: this share of the price
|
| 215 |
LOCKED_PREVIEW = ('<div class="cs-scores cs-empty"><div>🔒 The text is shown after payment. Your scores are above.'
|
| 216 |
'</div></div>')
|
| 217 |
|
|
|
|
| 507 |
meta.get("external_usd", 0), words, meta.get("name") or tool)["charged"]
|
| 508 |
j.extra["wallet"] = (charged, W.available(who))
|
| 509 |
else:
|
| 510 |
+
charged = W.settle(wallet["ref"], time.time() - t0, llm, words, meta.get("name") or tool,
|
| 511 |
+
factor=meta.get("price_factor", 1.0))["charged"]
|
| 512 |
j.extra["wallet"] = (charged, W.available(who))
|
| 513 |
if paid_order: # paid on the page (PhonePe): the order is used up
|
| 514 |
charged = W.finish_report_order(paid_order, int(meta.get("units_ok", 1)))
|
|
|
|
| 644 |
msg += (f"\n\n🔒 {locked} flagged sentence(s) could not be edited (citation fields, links, equations or "
|
| 645 |
"sub/superscripts inside the sentence) and keep their wording.")
|
| 646 |
# 0% is the promise: a document that did not reach it is delivered with the sentences still flagged highlighted,
|
| 647 |
+
# at SHORT_PRICE_FACTOR of the price from the wallet (the owner's rule, 2026-10-28: it was free, which lost money
|
| 648 |
+
# on every such run)
|
| 649 |
short = bool(res.get("zero_goal")) and a["pct"] > 0 and not res.get("report") and not stuck
|
| 650 |
if short:
|
| 651 |
msg = (f"⚠️ **AI score {b['pct']}% → {a['pct']}%.** {res.get('left') or 'A few'} sentence(s) still read as "
|
|
|
|
| 699 |
out[0] = H.status_done(msg + "\n\n🎁 **No charge for this run**, because the score was not lowered.")
|
| 700 |
if rep is not None and hasattr(rep, "meta"): # given away, not bought: it earns no free re-runs
|
| 701 |
rep.meta.update(no_charge=True)
|
| 702 |
+
if short: # lowered, not to 0%: half price (still above its cost)
|
| 703 |
+
if rep is not None and hasattr(rep, "meta"):
|
| 704 |
+
rep.meta.update(price_factor=SHORT_PRICE_FACTOR)
|
| 705 |
+
if S.G.WALLET_CAP.get() is not None: # a wallet run (credits and plans are used as usual)
|
| 706 |
+
out[0] = H.status_done(msg + f"\n\n💳 **Charged at {SHORT_PRICE_FACTOR:.0%} of the price**, because it "
|
| 707 |
+
"did not reach 0%.")
|
| 708 |
if charge: # pay per use: scores now, the document after payment
|
| 709 |
out = [H.status_done(msg) + pay_popup_html(charge, "smart"), LOCKED_PREVIEW, panel, None, ""]
|
| 710 |
if text_mode:
|
|
|
|
| 2928 |
gr.HTML('<div class="cs-settings-title">🏆 Most active users (30 days)</div>')
|
| 2929 |
ad_ov_top = gr.Dataframe(headers=["Name", "Email", "Runs", "Documents", "Words", "Last run"],
|
| 2930 |
interactive=False, wrap=True)
|
| 2931 |
+
# ---------- profit ----------
|
| 2932 |
+
with gr.Tab("Profit") as ad_pf_tab:
|
| 2933 |
+
with gr.Row():
|
| 2934 |
+
gr.HTML('<div class="cs-auth-title" style="text-align:left">Profit</div>')
|
| 2935 |
+
ad_pf_days = gr.Dropdown([("Last 7 days", 7), ("Last 30 days", 30), ("Last 90 days", 90)],
|
| 2936 |
+
value=30, label="Period", scale=0, min_width=160)
|
| 2937 |
+
ad_pf_refresh = gr.Button("Refresh", size="sm", scale=0, elem_classes="cs-topbtn")
|
| 2938 |
+
ad_pf_tiles = gr.HTML()
|
| 2939 |
+
with gr.Column(elem_classes="cs-card"):
|
| 2940 |
+
gr.HTML('<div class="cs-settings-title">Profit by tool</div>'
|
| 2941 |
+
'<div class="cs-gauge-cap" style="text-align:left">Revenue is what runs were charged '
|
| 2942 |
+
'(wallet and pay-on-page reports). Profit = revenue after GST and the payment fee, '
|
| 2943 |
+
'minus the language model and bought reports. The GPU bill is a fixed cost, shown '
|
| 2944 |
+
'above, not split by tool.</div>')
|
| 2945 |
+
ad_pf_table = gr.Dataframe(headers=W.PROFIT_COLS, interactive=False, wrap=True)
|
| 2946 |
# ---------- activity ----------
|
| 2947 |
with gr.Tab("Activity") as ad_act_tab:
|
| 2948 |
with gr.Row():
|
|
|
|
| 3156 |
tiles, chart, tools, top = DB.admin_overview(u)
|
| 3157 |
return DB._tiles(P.admin_revenue_tiles(u)) + tiles, chart, tools, top
|
| 3158 |
|
| 3159 |
+
def profit(u, days):
|
| 3160 |
+
A._need_admin(u)
|
| 3161 |
+
tiles, rows = W.profit_dashboard(int(days or 30))
|
| 3162 |
+
return DB._tiles(tiles), rows
|
| 3163 |
+
|
| 3164 |
+
for ev in (ad_pf_refresh.click, ad_pf_days.change, ad_pf_tab.select):
|
| 3165 |
+
ev(profit, [user_state, ad_pf_days], [ad_pf_tiles, ad_pf_table], **HID)
|
| 3166 |
ad_ov_refresh.click(overview, user_state, ov_out, **HID)
|
| 3167 |
ad_ov_tab.select(overview, user_state, ov_out, **HID)
|
| 3168 |
admin_tab.select(overview, user_state, ov_out, **HID)
|
plans.py
CHANGED
|
@@ -5,8 +5,9 @@ Unit: one CREDIT = one document of up to CREDIT_WORDS (15,000) words; a longer d
|
|
| 5 |
15,000 words (a 40,000-word thesis = 3 credits). The number is shown before the run and charged only after a
|
| 6 |
successful run (a failed run costs nothing). The same file can be re-run FREE_RERUNS times free within RERUN_DAYS.
|
| 7 |
|
| 8 |
-
guest paste only: 300 words a paste,
|
| 9 |
-
free
|
|
|
|
| 10 |
single ₹199 / $3 one credit, valid 6 months
|
| 11 |
pack ₹799 / $12 five credits, valid 6 months
|
| 12 |
scholar ₹999 / $14 a month 10 credits a month, pastes up to 1,500 words, unlimited detector
|
|
@@ -55,10 +56,11 @@ UNLIMITED = 10 ** 9
|
|
| 55 |
|
| 56 |
# docs_month = credits per subscription month; format_month = Journal Formatter files a month (display)
|
| 57 |
PLANS = {
|
| 58 |
-
"guest": dict(name="Guest", inr=0, usd=0, docs_month=0, docs_day=0, pastes=
|
|
|
|
| 59 |
doc_words=0, format_month=0, priority=False),
|
| 60 |
-
"free": dict(name="Free", inr=0, usd=0, docs_month=0, docs_day=0, pastes=
|
| 61 |
-
doc_words=_env_i("FREE_DOC_WORDS",
|
| 62 |
"scholar": dict(name="Scholar", inr=_env_i("PRICE_SCHOLAR", 999), usd=_env_f("PRICE_SCHOLAR_USD", 14),
|
| 63 |
docs_month=_env_i("SCHOLAR_CREDITS", 10), docs_day=0, pastes=UNLIMITED, paste_words=1500,
|
| 64 |
passes=10, doc_words=150000, format_month=5, priority=False),
|
|
@@ -776,10 +778,10 @@ def pay_instructions(user, item):
|
|
| 776 |
PAY_PER_USE = os.getenv("PAY_PER_USE", "1") != "0"
|
| 777 |
COST_GPU_HOUR_USD = _env_f("COST_GPU_HOUR_USD", 0.40)
|
| 778 |
COST_IDLE_FACTOR = max(1.0, _env_f("COST_IDLE_FACTOR", 5))
|
| 779 |
-
PRICE_MARGIN = _env_f("PRICE_MARGIN",
|
| 780 |
PRICE_GST = _env_f("PRICE_GST", 18)
|
| 781 |
PRICE_USD_INR = _env_f("PRICE_USD_INR", 85)
|
| 782 |
-
PRICE_MIN_INR = _env_f("PRICE_MIN_INR",
|
| 783 |
PAY_URL = os.getenv("PAY_URL", "").strip()
|
| 784 |
|
| 785 |
|
|
|
|
| 5 |
15,000 words (a 40,000-word thesis = 3 credits). The number is shown before the run and charged only after a
|
| 6 |
successful run (a failed run costs nothing). The same file can be re-run FREE_RERUNS times free within RERUN_DAYS.
|
| 7 |
|
| 8 |
+
guest paste only: 300 words a paste, 3 a day, 2 Smart passes
|
| 9 |
+
free 5 pastes a day (300 words); 1 trial document up to 1,500 words; 3 detector files a day
|
| 10 |
+
(trimmed 2026-10-28 so free use costs at most about Rs 15 a day per account)
|
| 11 |
single ₹199 / $3 one credit, valid 6 months
|
| 12 |
pack ₹799 / $12 five credits, valid 6 months
|
| 13 |
scholar ₹999 / $14 a month 10 credits a month, pastes up to 1,500 words, unlimited detector
|
|
|
|
| 56 |
|
| 57 |
# docs_month = credits per subscription month; format_month = Journal Formatter files a month (display)
|
| 58 |
PLANS = {
|
| 59 |
+
"guest": dict(name="Guest", inr=0, usd=0, docs_month=0, docs_day=0, pastes=_env_i("GUEST_PASTES", 3),
|
| 60 |
+
paste_words=300, passes=2,
|
| 61 |
doc_words=0, format_month=0, priority=False),
|
| 62 |
+
"free": dict(name="Free", inr=0, usd=0, docs_month=0, docs_day=0, pastes=_env_i("FREE_PASTES", 5),
|
| 63 |
+
paste_words=300, passes=2, doc_words=_env_i("FREE_DOC_WORDS", 1500), format_month=1, priority=False),
|
| 64 |
"scholar": dict(name="Scholar", inr=_env_i("PRICE_SCHOLAR", 999), usd=_env_f("PRICE_SCHOLAR_USD", 14),
|
| 65 |
docs_month=_env_i("SCHOLAR_CREDITS", 10), docs_day=0, pastes=UNLIMITED, paste_words=1500,
|
| 66 |
passes=10, doc_words=150000, format_month=5, priority=False),
|
|
|
|
| 778 |
PAY_PER_USE = os.getenv("PAY_PER_USE", "1") != "0"
|
| 779 |
COST_GPU_HOUR_USD = _env_f("COST_GPU_HOUR_USD", 0.40)
|
| 780 |
COST_IDLE_FACTOR = max(1.0, _env_f("COST_IDLE_FACTOR", 5))
|
| 781 |
+
PRICE_MARGIN = _env_f("PRICE_MARGIN", 400) # profit, percent of cost (as the wallet)
|
| 782 |
PRICE_GST = _env_f("PRICE_GST", 18)
|
| 783 |
PRICE_USD_INR = _env_f("PRICE_USD_INR", 85)
|
| 784 |
+
PRICE_MIN_INR = _env_f("PRICE_MIN_INR", 49) # smallest charge (a payment below this is not worth its fee)
|
| 785 |
PAY_URL = os.getenv("PAY_URL", "").strip()
|
| 786 |
|
| 787 |
|
tests/test_app_rephrase_anyway.py
CHANGED
|
@@ -136,9 +136,9 @@ def test_a_free_stuck_pay_per_use_run_is_marked_so_it_earns_no_free_reruns(monke
|
|
| 136 |
assert 'if not meta.get("no_charge")' in src and src.index('meta.get("no_charge")') < src.index("P.consume_doc")
|
| 137 |
|
| 138 |
|
| 139 |
-
def
|
| 140 |
-
"""
|
| 141 |
-
|
| 142 |
class Rep:
|
| 143 |
meta = {}
|
| 144 |
|
|
@@ -151,13 +151,21 @@ def test_a_document_not_at_zero_percent_is_never_charged(monkeypatch):
|
|
| 151 |
rep = Rep()
|
| 152 |
status = app._smart_result(r, 0.8, 0.5, False, rep, "paper.docx")[0]
|
| 153 |
assert "100% → 12%" in status and "2 sentence(s) still read as AI-written" in status
|
| 154 |
-
assert "No charge
|
| 155 |
-
assert rep.meta.get("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
rep2 = Rep()
|
| 157 |
rep2.meta = {}
|
| 158 |
app._smart_result(dict(_res(100, 0, log, new="Changed text here."), zero_goal=True, left=0), 0.8, 0.5, False,
|
| 159 |
rep2, "paper.docx")
|
| 160 |
-
assert len(locks) == 1 and not rep2.meta
|
| 161 |
|
| 162 |
|
| 163 |
def test_a_reconnecting_page_re_attaches_to_the_tools_job_instead_of_starting_again():
|
|
|
|
| 136 |
assert 'if not meta.get("no_charge")' in src and src.index('meta.get("no_charge")') < src.index("P.consume_doc")
|
| 137 |
|
| 138 |
|
| 139 |
+
def test_a_document_not_at_zero_percent_pays_half(monkeypatch):
|
| 140 |
+
"""A document the engine brought down but not to 0% is delivered (remaining sentences highlighted) at half price
|
| 141 |
+
(the owner's rule of 2026-10-28; it used to be free); a document at 0% is charged in full."""
|
| 142 |
class Rep:
|
| 143 |
meta = {}
|
| 144 |
|
|
|
|
| 151 |
rep = Rep()
|
| 152 |
status = app._smart_result(r, 0.8, 0.5, False, rep, "paper.docx")[0]
|
| 153 |
assert "100% → 12%" in status and "2 sentence(s) still read as AI-written" in status
|
| 154 |
+
assert "No charge" not in status
|
| 155 |
+
assert rep.meta.get("price_factor") == 0.5 and not rep.meta.get("no_charge")
|
| 156 |
+
tok = app.S.G.WALLET_CAP.set(1.0) # a wallet run says so on the page
|
| 157 |
+
try:
|
| 158 |
+
rep3 = Rep()
|
| 159 |
+
rep3.meta = {}
|
| 160 |
+
status = app._smart_result(r, 0.8, 0.5, False, rep3, "paper.docx")[0]
|
| 161 |
+
finally:
|
| 162 |
+
app.S.G.WALLET_CAP.reset(tok)
|
| 163 |
+
assert "Charged at 50% of the price" in status
|
| 164 |
rep2 = Rep()
|
| 165 |
rep2.meta = {}
|
| 166 |
app._smart_result(dict(_res(100, 0, log, new="Changed text here."), zero_goal=True, left=0), 0.8, 0.5, False,
|
| 167 |
rep2, "paper.docx")
|
| 168 |
+
assert len(locks) == 1 and "price_factor" not in rep2.meta # at 0%: charged in full
|
| 169 |
|
| 170 |
|
| 171 |
def test_a_reconnecting_page_re_attaches_to_the_tools_job_instead_of_starting_again():
|
tests/test_dashboard.py
CHANGED
|
@@ -49,7 +49,7 @@ def test_results_waiting_for_payment_are_shown_with_a_pay_link(user, tmp_path):
|
|
| 49 |
price = P.usage_price(300, 0.20)
|
| 50 |
ref = P.create_charge(user["email"], "Smart Humanize", "thesis.docx", 5120, price, str(tmp_path / "t.docx"))
|
| 51 |
h = DB._pending_html(user["email"])
|
| 52 |
-
assert "thesis.docx" in h and ref in h and "₹
|
| 53 |
tiles = DB.user_dashboard(user)[0]
|
| 54 |
assert ref in tiles
|
| 55 |
P.settle_charge(ref, source="admin")
|
|
|
|
| 49 |
price = P.usage_price(300, 0.20)
|
| 50 |
ref = P.create_charge(user["email"], "Smart Humanize", "thesis.docx", 5120, price, str(tmp_path / "t.docx"))
|
| 51 |
h = DB._pending_html(user["email"])
|
| 52 |
+
assert "thesis.docx" in h and ref in h and "₹184" in h and "Pay now" in h and 'target="_blank"' in h
|
| 53 |
tiles = DB.user_dashboard(user)[0]
|
| 54 |
assert ref in tiles
|
| 55 |
P.settle_charge(ref, source="admin")
|
tests/test_pricing.py
CHANGED
|
@@ -39,9 +39,9 @@ def test_price_is_cost_plus_margin_plus_gst():
|
|
| 39 |
assert p["gpu_usd"] == pytest.approx(300 * 0.40 / 3600)
|
| 40 |
assert p["space_usd"] == pytest.approx(p["gpu_usd"] * 4) # idle share of the always-on Space
|
| 41 |
assert p["cost_usd"] == pytest.approx(p["gpu_usd"] * 5 + 0.20)
|
| 42 |
-
assert p["profit_usd"] == pytest.approx(
|
| 43 |
-
assert p["gst_usd"] == pytest.approx(0.18 *
|
| 44 |
-
assert p["inr"] ==
|
| 45 |
assert P.usage_price(1, 0)["inr"] == P.PRICE_MIN_INR # never below the smallest charge
|
| 46 |
|
| 47 |
|
|
@@ -66,7 +66,7 @@ def test_charge_is_locked_until_it_is_settled(tmp_path, monkeypatch):
|
|
| 66 |
c = app._payg_lock(out, "Smart Humanize", "paper.docx", 4000, 300, None)
|
| 67 |
finally:
|
| 68 |
app.PAYG_FOR.reset(tok)
|
| 69 |
-
assert c["status"] == "pending" and c["amount_inr"] ==
|
| 70 |
user = {"email": "payer@example.com"}
|
| 71 |
status, file, dl = app.claim_paid(user)
|
| 72 |
assert "not confirmed yet" in status and "Pay now" in status and not isinstance(file, str)
|
|
|
|
| 39 |
assert p["gpu_usd"] == pytest.approx(300 * 0.40 / 3600)
|
| 40 |
assert p["space_usd"] == pytest.approx(p["gpu_usd"] * 4) # idle share of the always-on Space
|
| 41 |
assert p["cost_usd"] == pytest.approx(p["gpu_usd"] * 5 + 0.20)
|
| 42 |
+
assert p["profit_usd"] == pytest.approx(4 * p["cost_usd"]) # 400% margin: price before tax = 5 x cost
|
| 43 |
+
assert p["gst_usd"] == pytest.approx(0.18 * 5 * p["cost_usd"])
|
| 44 |
+
assert p["inr"] == 184 # $2.164 x 85, rounded up
|
| 45 |
assert P.usage_price(1, 0)["inr"] == P.PRICE_MIN_INR # never below the smallest charge
|
| 46 |
|
| 47 |
|
|
|
|
| 66 |
c = app._payg_lock(out, "Smart Humanize", "paper.docx", 4000, 300, None)
|
| 67 |
finally:
|
| 68 |
app.PAYG_FOR.reset(tok)
|
| 69 |
+
assert c["status"] == "pending" and c["amount_inr"] == 184 and c["ref"].startswith("CS-")
|
| 70 |
user = {"email": "payer@example.com"}
|
| 71 |
status, file, dl = app.claim_paid(user)
|
| 72 |
assert "not confirmed yet" in status and "Pay now" in status and not isinstance(file, str)
|
tests/test_profit_rules.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pricing of 2026-10-28: price = 5 x cost (after GST and fees), at least Rs 49; a document that came down but not to
|
| 2 |
+
0% pays half (still above its cost); the free tier is small; the admin's profit page adds up."""
|
| 3 |
+
import os
|
| 4 |
+
import tempfile
|
| 5 |
+
import uuid
|
| 6 |
+
|
| 7 |
+
os.environ.setdefault("USERS_DB", os.path.join(tempfile.mkdtemp(), "users.db"))
|
| 8 |
+
os.environ.setdefault("SKIP_MODEL_LOAD", "1")
|
| 9 |
+
import plans as P # noqa: E402
|
| 10 |
+
import wallet as W # noqa: E402
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _held(paise):
|
| 14 |
+
email = f"pf-{uuid.uuid4().hex[:8]}@example.com"
|
| 15 |
+
W.adjust(email, paise, "test money")
|
| 16 |
+
return email, W.reserve(email, paise, "Smart Humanize")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_margin_and_minimum():
|
| 20 |
+
assert W.MARGIN >= 400 and W.MIN_INR >= 49
|
| 21 |
+
assert W.price_paise(0) == 4900
|
| 22 |
+
b = W.breakdown(300, 0.40)
|
| 23 |
+
assert b["inr"] - b["gst_inr"] - b["fee_inr"] >= 5 * b["cost_usd"] * W.USD_INR - 0.01
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_half_price_when_not_at_zero_still_covers_the_cost():
|
| 27 |
+
email, ref = _held(100000)
|
| 28 |
+
full = W.breakdown(300, 0.40)["paise"]
|
| 29 |
+
got = W.settle(ref, 300, 0.40, 3500, "x.docx", factor=0.5)["charged"]
|
| 30 |
+
assert abs(got - full / 2) <= 100
|
| 31 |
+
cost_inr = W.breakdown(300, 0.40)["cost_usd"] * W.USD_INR
|
| 32 |
+
assert got / 100 * W._KEEP > cost_inr # half price is still above the cost
|
| 33 |
+
assert W.balance(email) == 100000 - got
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_half_price_never_below_the_minimum():
|
| 37 |
+
_, ref = _held(10000)
|
| 38 |
+
assert W.settle(ref, 1, 0.0, 50, "tiny", factor=0.5)["charged"] == int(W.MIN_INR * 100)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_turnitin_price_keeps_a_minimum_margin():
|
| 42 |
+
secs, llm = W.usual("turnitin", 0)
|
| 43 |
+
p = W.usual_price("turnitin", 0)
|
| 44 |
+
assert p / 100 * W._KEEP >= (W.gpu_usd(secs) + llm) * W.USD_INR * (1 + W.SET_MIN_MARGIN / 100) - 1
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_free_tier_is_trimmed():
|
| 48 |
+
assert P.PLANS["free"]["pastes"] <= 5 and P.PLANS["guest"]["pastes"] <= 3
|
| 49 |
+
assert P.PLANS["free"]["doc_words"] <= 1500
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_profit_dashboard_adds_up():
|
| 53 |
+
tool = f"Test tool {uuid.uuid4().hex[:6]}"
|
| 54 |
+
W.record_cost("a@example.com", tool, 2500, 120, 0.30, 17500, "wallet")
|
| 55 |
+
W.record_cost("b@example.com", tool, 300, 20, 0.04, 0, "free")
|
| 56 |
+
tiles, rows = W.profit_dashboard(7)
|
| 57 |
+
row = next(r for r in rows if r[0] == tool)
|
| 58 |
+
assert row[1:4] == [2, 1, 1] and row[4] == "₹175"
|
| 59 |
+
profit = 175 * W._KEEP - (0.34 * W.USD_INR)
|
| 60 |
+
assert row[6] == f"₹{profit:,.0f}"
|
| 61 |
+
labels = [t[0] for t in tiles]
|
| 62 |
+
assert "Net profit (7d)" in labels and "GPU bill (always on)" in labels and "Cost of free use" in labels
|
tests/test_result_email.py
CHANGED
|
@@ -90,7 +90,7 @@ def test_document_run_through_the_job_runner_is_mailed(monkeypatch, tmp_path):
|
|
| 90 |
rep.meta.update(name="synopsis.docx", words=3539, before=18, after=1, output=str(out), no_charge=True)
|
| 91 |
return ("done", None, None, str(out), "")
|
| 92 |
|
| 93 |
-
frames = list(app._stream(core, (str(doc),), 5, 0, user, True, None, tool="Smart Humanize", words=
|
| 94 |
assert sent and sent[0][0] == email and str(out) in sent[0][1], frames[-1][0][:300]
|
| 95 |
assert "emailed" in frames[-1][0]
|
| 96 |
|
|
|
|
| 90 |
rep.meta.update(name="synopsis.docx", words=3539, before=18, after=1, output=str(out), no_charge=True)
|
| 91 |
return ("done", None, None, str(out), "")
|
| 92 |
|
| 93 |
+
frames = list(app._stream(core, (str(doc),), 5, 0, user, True, None, tool="Smart Humanize", words=1200))
|
| 94 |
assert sent and sent[0][0] == email and str(out) in sent[0][1], frames[-1][0][:300]
|
| 95 |
assert "emailed" in frames[-1][0]
|
| 96 |
|
wallet.py
CHANGED
|
@@ -35,12 +35,12 @@ def _f(k, d):
|
|
| 35 |
|
| 36 |
GPU_HOUR_USD = _f("COST_GPU_HOUR_USD", 0.40) # Hugging Face Nvidia T4 small
|
| 37 |
IDLE_FACTOR = max(1.0, _f("COST_IDLE_FACTOR", 5)) # each busy GPU second carries this many seconds of the bill
|
| 38 |
-
MARGIN = _f("PRICE_MARGIN",
|
| 39 |
GST = _f("PRICE_GST", 18)
|
| 40 |
FEE = _f("PAY_FEE_PCT", 2.0) # payment gateway fee, % of the amount paid ...
|
| 41 |
FEE_GST = _f("PAY_FEE_GST", 18) # ... plus GST on that fee
|
| 42 |
USD_INR = _f("PRICE_USD_INR", 85)
|
| 43 |
-
MIN_INR = _f("PRICE_MIN_INR",
|
| 44 |
HOLD_X = _f("WALLET_HOLD_X", 2.0) # reservation = this x the usual price of the job
|
| 45 |
TOPUPS = [int(x) for x in os.getenv("WALLET_TOPUPS", "100,250,500,1000").split(",") if x.strip()]
|
| 46 |
TOPUP_MIN, TOPUP_MAX = int(_f("WALLET_TOPUP_MIN", 50)), int(_f("WALLET_TOPUP_MAX", 20000))
|
|
@@ -101,14 +101,15 @@ def usual(kind, words):
|
|
| 101 |
MARGINS = {"turnitin": _f("REILAA_MARGIN", MARGIN)}
|
| 102 |
|
| 103 |
|
| 104 |
-
# a set price per run (₹, incl. GST); never below
|
| 105 |
SET_PRICE = {"turnitin": _f("REILAA_PRICE_INR", 550)}
|
|
|
|
| 106 |
|
| 107 |
|
| 108 |
def usual_price(kind, words):
|
| 109 |
secs, llm = usual(kind, words)
|
| 110 |
if SET_PRICE.get(kind):
|
| 111 |
-
floor = price_paise(gpu_usd(secs) + llm,
|
| 112 |
return max(int(round(SET_PRICE[kind])) * 100, floor)
|
| 113 |
return price_paise(gpu_usd(secs) + llm, MARGINS.get(kind))
|
| 114 |
|
|
@@ -198,10 +199,13 @@ def reserve(email, paise, tool=""):
|
|
| 198 |
return ref
|
| 199 |
|
| 200 |
|
| 201 |
-
def settle(ref, seconds, llm_usd, words=0, name=""):
|
| 202 |
"""Charge a finished job its actual price (at most what was reserved) and release the rest. Returns the
|
| 203 |
-
breakdown with the amount charged.
|
|
|
|
| 204 |
b = breakdown(seconds, llm_usd)
|
|
|
|
|
|
|
| 205 |
with _lock, _db() as con:
|
| 206 |
row = con.execute("SELECT email, paise, tool, status FROM wallet_holds WHERE ref=?", (ref,)).fetchone()
|
| 207 |
if not row or row[3] != "active":
|
|
@@ -500,6 +504,70 @@ def profit_report(days=30):
|
|
| 500 |
jobs=jobs, paid_jobs=paid_jobs, gpu_hours_busy=secs / 3600, breakeven_jobs=breakeven)
|
| 501 |
|
| 502 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
# ---------------------------------------------------------------------------------------------------- pricing page
|
| 504 |
def pricing_html(free_note=""):
|
| 505 |
"""The Pricing page: pay as you go from a prepaid wallet, with the usual price of common jobs."""
|
|
@@ -521,7 +589,9 @@ def pricing_html(free_note=""):
|
|
| 521 |
f'</div><ul><li>Add {tops} or any amount from {inr(TOPUP_MIN * 100)}: UPI, cards, net banking '
|
| 522 |
f'(PhonePe)</li><li>Each run is priced by what it actually used (its length and the work it needed)'
|
| 523 |
f'</li><li>The usual price is shown before a run starts; you are never charged more than it reserved'
|
| 524 |
-
f'</li><li>A
|
|
|
|
|
|
|
| 525 |
f'<li>Prices include {GST:.0f}% GST</li></ul></div>'
|
| 526 |
f'<table class="cs-wallet-table" style="width:100%;margin-top:14px;border-collapse:collapse">'
|
| 527 |
f'<thead><tr><th style="text-align:left">Usual price of a run</th><th style="text-align:left">Length'
|
|
|
|
| 35 |
|
| 36 |
GPU_HOUR_USD = _f("COST_GPU_HOUR_USD", 0.40) # Hugging Face Nvidia T4 small
|
| 37 |
IDLE_FACTOR = max(1.0, _f("COST_IDLE_FACTOR", 5)) # each busy GPU second carries this many seconds of the bill
|
| 38 |
+
MARGIN = _f("PRICE_MARGIN", 400) # profit, % of cost (price = 5 x cost, after GST and fees)
|
| 39 |
GST = _f("PRICE_GST", 18)
|
| 40 |
FEE = _f("PAY_FEE_PCT", 2.0) # payment gateway fee, % of the amount paid ...
|
| 41 |
FEE_GST = _f("PAY_FEE_GST", 18) # ... plus GST on that fee
|
| 42 |
USD_INR = _f("PRICE_USD_INR", 85)
|
| 43 |
+
MIN_INR = _f("PRICE_MIN_INR", 49) # smallest charge: covers the fee, start-up and after-work
|
| 44 |
HOLD_X = _f("WALLET_HOLD_X", 2.0) # reservation = this x the usual price of the job
|
| 45 |
TOPUPS = [int(x) for x in os.getenv("WALLET_TOPUPS", "100,250,500,1000").split(",") if x.strip()]
|
| 46 |
TOPUP_MIN, TOPUP_MAX = int(_f("WALLET_TOPUP_MIN", 50)), int(_f("WALLET_TOPUP_MAX", 20000))
|
|
|
|
| 101 |
MARGINS = {"turnitin": _f("REILAA_MARGIN", MARGIN)}
|
| 102 |
|
| 103 |
|
| 104 |
+
# a set price per run (₹, incl. GST); never below cost + SET_MIN_MARGIN %, so every run stays in profit
|
| 105 |
SET_PRICE = {"turnitin": _f("REILAA_PRICE_INR", 550)}
|
| 106 |
+
SET_MIN_MARGIN = _f("SET_PRICE_MIN_MARGIN", 30)
|
| 107 |
|
| 108 |
|
| 109 |
def usual_price(kind, words):
|
| 110 |
secs, llm = usual(kind, words)
|
| 111 |
if SET_PRICE.get(kind):
|
| 112 |
+
floor = price_paise(gpu_usd(secs) + llm, SET_MIN_MARGIN) # cost + GST + fee + a minimum profit
|
| 113 |
return max(int(round(SET_PRICE[kind])) * 100, floor)
|
| 114 |
return price_paise(gpu_usd(secs) + llm, MARGINS.get(kind))
|
| 115 |
|
|
|
|
| 199 |
return ref
|
| 200 |
|
| 201 |
|
| 202 |
+
def settle(ref, seconds, llm_usd, words=0, name="", factor=1.0):
|
| 203 |
"""Charge a finished job its actual price (at most what was reserved) and release the rest. Returns the
|
| 204 |
+
breakdown with the amount charged. factor < 1: a reduced price (a run that did not reach 0% pays half), never
|
| 205 |
+
below the minimum charge."""
|
| 206 |
b = breakdown(seconds, llm_usd)
|
| 207 |
+
if factor < 1:
|
| 208 |
+
b["paise"] = max(int(MIN_INR * 100), int(math.ceil(b["paise"] * max(0.0, factor) / 100)) * 100)
|
| 209 |
with _lock, _db() as con:
|
| 210 |
row = con.execute("SELECT email, paise, tool, status FROM wallet_holds WHERE ref=?", (ref,)).fetchone()
|
| 211 |
if not row or row[3] != "active":
|
|
|
|
| 504 |
jobs=jobs, paid_jobs=paid_jobs, gpu_hours_busy=secs / 3600, breakeven_jobs=breakeven)
|
| 505 |
|
| 506 |
|
| 507 |
+
FIXED_MONTH_INR = _f("COST_FIXED_MONTH_INR", 0) # other monthly bills (domain, storage, mail ...), ₹
|
| 508 |
+
PROFIT_COLS = ["Tool", "Runs", "Paid runs", "Free / plan runs", "Revenue (incl. GST)", "Direct cost",
|
| 509 |
+
"Profit", "Margin"]
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
def profit_dashboard(days=30):
|
| 513 |
+
"""The admin's profit page: (tiles [(label, value)], rows per tool) for the last `days` days, in ₹.
|
| 514 |
+
Revenue = what runs were charged (wallet and pay-on-page reports); out of it go GST and the gateway fee.
|
| 515 |
+
Direct cost = the language model and bought reports (Turnitin) of every run, paid or free. Fixed cost = the
|
| 516 |
+
GPU billed every hour, busy or not, plus COST_FIXED_MONTH_INR."""
|
| 517 |
+
since = f"-{int(days)} days"
|
| 518 |
+
with _db() as con:
|
| 519 |
+
rows = con.execute("SELECT tool, how, COUNT(*), COALESCE(SUM(charged_paise),0), COALESCE(SUM(llm_usd),0), "
|
| 520 |
+
"COALESCE(SUM(seconds),0), COALESCE(SUM(charged_paise > 0),0) FROM job_costs "
|
| 521 |
+
"WHERE ts >= datetime('now', ?) GROUP BY tool, how", (since,)).fetchall()
|
| 522 |
+
losers = con.execute("SELECT COUNT(*) FROM job_costs WHERE ts >= datetime('now', ?) AND charged_paise > 0 "
|
| 523 |
+
"AND charged_paise / 100.0 * ? < llm_usd * ?", (since, _KEEP, USD_INR)).fetchone()[0]
|
| 524 |
+
keep = lambda inr_: inr_ * _KEEP # what stays after GST and the gateway fee
|
| 525 |
+
tools, tot = {}, dict(runs=0, paid=0, free=0, rev=0.0, cost=0.0, free_cost=0.0, secs=0.0)
|
| 526 |
+
for tool, how, n, paise, llm, secs, paid in rows:
|
| 527 |
+
t = tools.setdefault(tool or "—", dict(runs=0, paid=0, free=0, rev=0.0, cost=0.0))
|
| 528 |
+
cost = llm * USD_INR
|
| 529 |
+
free = n - paid
|
| 530 |
+
for d in (t, tot):
|
| 531 |
+
d["runs"] += n
|
| 532 |
+
d["paid"] += paid
|
| 533 |
+
d["free"] += free
|
| 534 |
+
d["rev"] += paise / 100
|
| 535 |
+
d["cost"] += cost
|
| 536 |
+
tot["secs"] += secs
|
| 537 |
+
if how in ("free", "plan") or not paise:
|
| 538 |
+
tot["free_cost"] += cost
|
| 539 |
+
gpu_bill = days * 24 * GPU_HOUR_USD * USD_INR
|
| 540 |
+
fixed = gpu_bill + FIXED_MONTH_INR * days / 30
|
| 541 |
+
gst = tot["rev"] - tot["rev"] / (1 + GST / 100)
|
| 542 |
+
fee = tot["rev"] - gst - keep(tot["rev"])
|
| 543 |
+
gross = keep(tot["rev"]) - tot["cost"] # before the fixed bills
|
| 544 |
+
net = gross - fixed
|
| 545 |
+
per_paid = gross / tot["paid"] if tot["paid"] else 0
|
| 546 |
+
need = math.ceil(fixed / per_paid) if per_paid > 0 else None
|
| 547 |
+
r = lambda v: f"₹{v:,.0f}"
|
| 548 |
+
tiles = [
|
| 549 |
+
(f"Net profit ({days}d)", ("" if net >= 0 else "−") + r(abs(net))),
|
| 550 |
+
("Revenue (incl. GST)", r(tot["rev"])), ("GST to pay", r(gst)), ("Payment fees", r(fee)),
|
| 551 |
+
("Language model + reports", r(tot["cost"])), ("GPU bill (always on)", r(gpu_bill)),
|
| 552 |
+
("Other fixed bills", r(fixed - gpu_bill)),
|
| 553 |
+
("Profit before fixed bills", r(gross)),
|
| 554 |
+
("Paid runs", f"{tot['paid']:,}"), ("Free / plan runs", f"{tot['free']:,}"),
|
| 555 |
+
("Cost of free use", r(tot["free_cost"])),
|
| 556 |
+
("Avg profit per paid run", r(per_paid) if tot["paid"] else "—"),
|
| 557 |
+
("Paid runs to cover fixed bills", f"{need:,}" if need else "—"),
|
| 558 |
+
("Paid runs below cost", f"{losers:,}"),
|
| 559 |
+
("GPU busy", f"{tot['secs'] / 3600:,.1f} h of {days * 24:,} h"),
|
| 560 |
+
("Price rule", f"cost × {1 + MARGIN / 100:g}, min {inr(int(MIN_INR * 100))}"),
|
| 561 |
+
]
|
| 562 |
+
out = []
|
| 563 |
+
for name, t in sorted(tools.items(), key=lambda kv: -kv[1]["rev"]):
|
| 564 |
+
profit = keep(t["rev"]) - t["cost"]
|
| 565 |
+
out.append([name, t["runs"], t["paid"], t["free"], r(t["rev"]), r(t["cost"]),
|
| 566 |
+
("" if profit >= 0 else "−") + r(abs(profit)),
|
| 567 |
+
f"{100 * profit / t['cost']:.0f}%" if t["cost"] else "—"])
|
| 568 |
+
return tiles, out
|
| 569 |
+
|
| 570 |
+
|
| 571 |
# ---------------------------------------------------------------------------------------------------- pricing page
|
| 572 |
def pricing_html(free_note=""):
|
| 573 |
"""The Pricing page: pay as you go from a prepaid wallet, with the usual price of common jobs."""
|
|
|
|
| 589 |
f'</div><ul><li>Add {tops} or any amount from {inr(TOPUP_MIN * 100)}: UPI, cards, net banking '
|
| 590 |
f'(PhonePe)</li><li>Each run is priced by what it actually used (its length and the work it needed)'
|
| 591 |
f'</li><li>The usual price is shown before a run starts; you are never charged more than it reserved'
|
| 592 |
+
f'</li><li>A humanized document that comes down but does not reach 0% AI is charged half price; one '
|
| 593 |
+
f'whose score does not come down costs nothing</li>'
|
| 594 |
+
f'<li>A run that fails or is stopped costs nothing</li><li>Your balance never expires</li>'
|
| 595 |
f'<li>Prices include {GST:.0f}% GST</li></ul></div>'
|
| 596 |
f'<table class="cs-wallet-table" style="width:100%;margin-top:14px;border-collapse:collapse">'
|
| 597 |
f'<thead><tr><th style="text-align:left">Usual price of a run</th><th style="text-align:left">Length'
|