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)