Recently Written · git

subplz-web

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

Log | Files | Refs


backend/billing.py (10861 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. Each
11           visitor gets some free credits (settings.free_credits_*); a job
12           spends those before the bought ones.
13 
14 All of the code is public and anyone may host it. What is sold is the use of
15 this operator's machines.
16 
17 Disabled on localhost (SUBPLZ_WEB_BILLING_ENABLED=false) so nothing gets in the
18 way while you use it yourself.
19 
20 Taking the money lives in payments.py; this file only decides who may do what.
21 """
22 from __future__ import annotations
23 
24 from dataclasses import dataclass
25 from datetime import date, datetime, timedelta, timezone
26 
27 from sqlalchemy import func, or_, update
28 from sqlalchemy.orm import Session
29 
30 from . import pricing
31 from .db import Account, Job, JobStatus, SessionLocal, utcnow
32 from .settings import settings
33 
34 FREE = "free"
35 CLOUD = "cloud"
36 TIERS = (FREE, CLOUD)
37 
38 # Stripe keeps retrying a failed renewal for a while; do not lock someone out
39 # the second their card hiccups.
40 _SUBSCRIBED = {"active", "trialing", "past_due"}
41 _GRACE = timedelta(days=1)
42 
43 
44 class PaymentRequired(RuntimeError):
45     """The account cannot do this without paying. Carries the reason to show."""
46 
47 
48 def _aware(when: datetime | None) -> datetime | None:
49     if when is not None and when.tzinfo is None:  # SQLite hands back naive
50         return when.replace(tzinfo=timezone.utc)
51     return when
52 
53 
54 @dataclass(frozen=True)
55 class Entitlement:
56     # Every credit the account can spend now: bought, free and monthly ones.
57     credits: int
58     # The part of `credits` that is free.
59     free_credits: int
60     # The part of `credits` that is left of this month's plan.
61     plan_credits: int
62     subscribed: bool
63     # A subscription with no monthly limit: no job spends a credit.
64     unlimited: bool
65     subscription_ends: datetime | None
66 
67     @property
68     def cloud_allowed(self) -> bool:
69         return self.unlimited or self.credits > 0
70 
71 
72 def is_subscribed(account: Account) -> bool:
73     if account.subscription_status not in _SUBSCRIBED:
74         return False
75     ends = _aware(account.subscription_period_end)
76     return ends is None or ends + _GRACE > utcnow()
77 
78 
79 def subscription_plan(account: Account) -> pricing.Plan | None:
80     """The plan of an active subscription. None without one, or when the plan
81     is not in the catalogue (a sold plan must be retired, not deleted)."""
82     if not is_subscribed(account):
83         return None
84     return pricing.get(account.subscription_plan_id or "", retired=True)
85 
86 
87 def is_unlimited(account: Account) -> bool:
88     plan = subscription_plan(account)
89     return plan is not None and plan.recurring and plan.credits is None
90 
91 
92 def plan_credits_left(account: Account) -> int:
93     plan = subscription_plan(account)
94     if plan is None or not plan.recurring or plan.credits is None:
95         return 0
96     return max(0, plan.credits - account.subscription_credits_used)
97 
98 
99 def _today() -> date:
100     """The day of the daily free credit (UTC). Tests replace this."""
101     return utcnow().date()
102 
103 
104 def _books_converted(session: Session, account: Account) -> int:
105     """Books that the server converted for `account`.
106 
107     Books converted in the browser do not count: the server cannot check
108     that a browser did the work.
109     """
110     return (
111         session.query(func.count(Job.id))
112         .filter(Job.account_id == account.id, Job.local == 0,
113                 Job.status == JobStatus.succeeded)
114         .scalar()
115         or 0
116     )
117 
118 
119 def free_allowance(session: Session, account: Account, verified: bool | None = None) -> int:
120     """The free credits (not the daily one) that `account` gets in all.
121 
122     Zero while this server takes no conversions: a free credit must not
123     promise a job that cannot run. Give `verified=True` to get the number
124     after the email is verified.
125     """
126     if not settings.cloud_enabled:
127         return 0
128     if verified is None:
129         verified = bool(account.email_verified)
130     if not verified:
131         return settings.free_credits_anonymous
132     bonus = 0
133     if settings.books_per_bonus_credit > 0:
134         bonus = _books_converted(session, account) // settings.books_per_bonus_credit
135     return settings.free_credits_verified + bonus
136 
137 
138 def free_credits_left(session: Session, account: Account, verified: bool | None = None) -> int:
139     return max(0, free_allowance(session, account, verified) - account.free_credits_used)
140 
141 
142 def daily_credit_left(account: Account) -> int:
143     """1 while a verified account has not spent the free credit of today."""
144     if not (settings.cloud_enabled and settings.daily_free_credit and account.email_verified):
145         return 0
146     return 0 if account.daily_credit_on == _today() else 1
147 
148 
149 def check(session: Session, account: Account) -> Entitlement:
150     free = free_credits_left(session, account) + daily_credit_left(account)
151     monthly = plan_credits_left(account)
152     return Entitlement(
153         credits=account.purchased_credits + free + monthly,
154         free_credits=free,
155         plan_credits=monthly,
156         subscribed=is_subscribed(account),
157         unlimited=is_unlimited(account),
158         subscription_ends=_aware(account.subscription_period_end),
159     )
160 
161 
162 def _spend_plan_credit(session: Session, account: Account) -> bool:
163     """Take one book of this month's plan, atomically. False if none is left."""
164     plan = subscription_plan(account)
165     if plan is None or not plan.recurring or plan.credits is None:
166         return False
167     taken = session.execute(
168         update(Account)
169         .where(Account.id == account.id,
170                Account.subscription_credits_used < plan.credits)
171         .values(subscription_credits_used=Account.subscription_credits_used + 1)
172     ).rowcount
173     session.refresh(account)
174     return bool(taken)
175 
176 
177 def _spend_daily_credit(session: Session, account: Account) -> date | None:
178     """Take the free credit of today, atomically. Its day, or None."""
179     if not daily_credit_left(account):
180         return None
181     today = _today()
182     taken = session.execute(
183         update(Account)
184         .where(
185             Account.id == account.id,
186             or_(Account.daily_credit_on.is_(None), Account.daily_credit_on != today),
187         )
188         .values(daily_credit_on=today)
189     ).rowcount
190     session.refresh(account)
191     return today if taken else None
192 
193 
194 def _spend_free_credit(session: Session, account: Account) -> bool:
195     """Take one free credit, atomically. False if there was none to take."""
196     taken = session.execute(
197         update(Account)
198         .where(
199             Account.id == account.id,
200             Account.free_credits_used < free_allowance(session, account),
201         )
202         .values(free_credits_used=Account.free_credits_used + 1)
203     ).rowcount
204     session.refresh(account)
205     return bool(taken)
206 
207 
208 def _spend_credit(session: Session, account: Account) -> bool:
209     """Take one credit, atomically. False if there was none to take."""
210     taken = session.execute(
211         update(Account)
212         .where(Account.id == account.id, Account.purchased_credits > 0)
213         .values(purchased_credits=Account.purchased_credits - 1)
214     ).rowcount
215     session.refresh(account)
216     return bool(taken)
217 
218 
219 def authorize_start(session: Session, account: Account, job: Job) -> None:
220     """Charge what starting `job` costs, or raise PaymentRequired.
221 
222     Where the job runs sets its tier: a browser job is free, a server job is
223     a cloud job. An unlimited plan pays for a cloud job. Else the job spends
224     the credit that ends first: the daily credit, then a book of this month's
225     plan, then a free credit, then a bought one.
226     """
227     job.tier = FREE if job.local else CLOUD
228     job.billed, job.credit_spent, job.free_credit_spent = 0, 0, 0
229     job.plan_credit_spent, job.daily_credit_on = 0, None
230     if job.local or not settings.billing_enabled or is_unlimited(account):
231         return
232     day = _spend_daily_credit(session, account)
233     if day is not None:
234         job.daily_credit_on = day
235         return
236     if _spend_plan_credit(session, account):
237         job.plan_credit_spent = 1
238         return
239     if _spend_free_credit(session, account):
240         job.free_credit_spent = 1
241         return
242     if not _spend_credit(session, account):
243         raise PaymentRequired(
244             "A conversion on our servers takes one credit. "
245             "In your browser it is free, without limit."
246         )
247     job.credit_spent = 1
248 
249 
250 def refund(session: Session, job: Job) -> None:
251     """Hand back whatever a failed or cancelled job was holding."""
252     job.billed = 0
253     if job.credit_spent:
254         session.execute(
255             update(Account)
256             .where(Account.id == job.account_id)
257             .values(purchased_credits=Account.purchased_credits + 1)
258         )
259         job.credit_spent = 0
260     if job.free_credit_spent:
261         session.execute(
262             update(Account)
263             .where(Account.id == job.account_id, Account.free_credits_used > 0)
264             .values(free_credits_used=Account.free_credits_used - 1)
265         )
266         job.free_credit_spent = 0
267     if job.plan_credit_spent:
268         session.execute(
269             update(Account)
270             .where(Account.id == job.account_id, Account.subscription_credits_used > 0)
271             .values(subscription_credits_used=Account.subscription_credits_used - 1)
272         )
273         job.plan_credit_spent = 0
274     if job.daily_credit_on is not None:
275         # Only the credit of the same day comes back: a later day has its own.
276         session.execute(
277             update(Account)
278             .where(Account.id == job.account_id,
279                    Account.daily_credit_on == job.daily_credit_on)
280             .values(daily_credit_on=None)
281         )
282         job.daily_credit_on = None
283     session.add(job)
284 
285 
286 def refund_job(job_id: str) -> None:
287     """`refund`, for the runner, which works in job ids rather than sessions."""
288     with SessionLocal() as session:
289         job = session.get(Job, job_id)
290         if job is not None:
291             refund(session, job)
292             session.commit()
293 
294 
295 def is_paid(job: Job) -> bool:
296     """True for a server job that money pays for: a bought credit, a book of a
297     monthly plan, or an unlimited plan. False for a free credit, the daily
298     credit, and a browser job. Only paid jobs may start a burst worker."""
299     return not (job.local or job.free_credit_spent or job.daily_credit_on is not None)