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()