Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


backend/billing.py (4106 bytes)

1 """Entitlement: what an account may do right now.
2 
3 The line between free and paid is where the work is done, not what comes out:
4 
5   free    The job runs in the visitor's browser, on the visitor's machine. It
6           costs this server nothing, so it has no price and no limit, and it
7           gives each output: subtitles, videos, the read-along book.
8   cloud   The job runs on this server's hardware: a large speech model on a
9           GPU, minutes and not hours, from any device. One credit for a book,
10           or nothing on a recurring plan if the operator sells one.
11 
12 All of the code is public and anyone may host it. What is sold is the use of
13 this operator's machines.
14 
15 Disabled on localhost (SUBPLZ_WEB_BILLING_ENABLED=false) so nothing gets in the
16 way while you use it yourself.
17 
18 Taking the money lives in payments.py; this file only decides who may do what.
19 """
20 from __future__ import annotations
21 
22 from dataclasses import dataclass
23 from datetime import datetime, timedelta, timezone
24 
25 from sqlalchemy import update
26 from sqlalchemy.orm import Session
27 
28 from .db import Account, Job, SessionLocal, utcnow
29 from .settings import settings
30 
31 FREE = "free"
32 CLOUD = "cloud"
33 TIERS = (FREE, CLOUD)
34 
35 # Stripe keeps retrying a failed renewal for a while; do not lock someone out
36 # the second their card hiccups.
37 _SUBSCRIBED = {"active", "trialing", "past_due"}
38 _GRACE = timedelta(days=1)
39 
40 
41 class PaymentRequired(RuntimeError):
42     """The account cannot do this without paying. Carries the reason to show."""
43 
44 
45 def _aware(when: datetime | None) -> datetime | None:
46     if when is not None and when.tzinfo is None:  # SQLite hands back naive
47         return when.replace(tzinfo=timezone.utc)
48     return when
49 
50 
51 @dataclass(frozen=True)
52 class Entitlement:
53     credits: int
54     subscribed: bool
55     subscription_ends: datetime | None
56 
57     @property
58     def cloud_allowed(self) -> bool:
59         return self.subscribed or self.credits > 0
60 
61 
62 def is_subscribed(account: Account) -> bool:
63     if account.subscription_status not in _SUBSCRIBED:
64         return False
65     ends = _aware(account.subscription_period_end)
66     return ends is None or ends + _GRACE > utcnow()
67 
68 
69 def check(account: Account) -> Entitlement:
70     return Entitlement(
71         credits=account.purchased_credits,
72         subscribed=is_subscribed(account),
73         subscription_ends=_aware(account.subscription_period_end),
74     )
75 
76 
77 def _spend_credit(session: Session, account: Account) -> bool:
78     """Take one credit, atomically. False if there was none to take."""
79     taken = session.execute(
80         update(Account)
81         .where(Account.id == account.id, Account.purchased_credits > 0)
82         .values(purchased_credits=Account.purchased_credits - 1)
83     ).rowcount
84     session.refresh(account)
85     return bool(taken)
86 
87 
88 def authorize_start(session: Session, account: Account, job: Job) -> None:
89     """Charge what starting `job` costs, or raise PaymentRequired.
90 
91     Where the job runs sets its tier: a browser job is free, a server job is
92     a cloud job.
93     """
94     job.tier = FREE if job.local else CLOUD
95     job.billed, job.credit_spent = 0, 0
96     if job.local or not settings.billing_enabled or is_subscribed(account):
97         return
98     if not _spend_credit(session, account):
99         raise PaymentRequired(
100             "A conversion on our servers takes one credit. "
101             "In your browser it is free, without limit."
102         )
103     job.credit_spent = 1
104 
105 
106 def refund(session: Session, job: Job) -> None:
107     """Hand back whatever a failed or cancelled job was holding."""
108     job.billed = 0
109     if job.credit_spent:
110         session.execute(
111             update(Account)
112             .where(Account.id == job.account_id)
113             .values(purchased_credits=Account.purchased_credits + 1)
114         )
115         job.credit_spent = 0
116     session.add(job)
117 
118 
119 def refund_job(job_id: str) -> None:
120     """`refund`, for the runner, which works in job ids rather than sessions."""
121     with SessionLocal() as session:
122         job = session.get(Job, job_id)
123         if job is not None:
124             refund(session, job)
125             session.commit()