backend/play.py (13317 bytes)
1 """Google Play purchases of cloud caption hours: the Play build of Subrep. 2 3 Google Play requires its own billing for digital goods that an app from Google 4 Play sells. So the Play build of Subrep buys the hour packs of captions.PACKS 5 with Google Play Billing. The Play product ids are the pack ids. 6 7 The app sends each purchase token here with its device cookie. This module asks 8 Google for the purchase, adds the hours of the pack to the account once, and 9 consumes the purchase, so that the user can buy the pack again. The app never 10 consumes. If this server does not answer, the app sends the purchase again at 11 its next start. 12 13 A buyer can get a refund from Google without the owner. So each hour, 14 main.housekeeping asks Google for the purchases that it refunded or charged 15 back, and this module takes their hours back once. 16 """ 17 from __future__ import annotations 18 19 import functools 20 import hashlib 21 import logging 22 from datetime import datetime 23 from typing import Annotated 24 from urllib.parse import quote 25 26 from fastapi import APIRouter, Depends, HTTPException 27 from pydantic import BaseModel, Field 28 from sqlalchemy import ( 29 Boolean, 30 DateTime, 31 ForeignKey, 32 Integer, 33 String, 34 case, 35 select, 36 update, 37 ) 38 from sqlalchemy.exc import IntegrityError 39 from sqlalchemy.orm import Mapped, Session, mapped_column 40 41 from . import accounts, captions 42 from .api import get_account, get_session 43 from .db import Account, Base, new_id, utcnow 44 from .settings import settings 45 46 log = logging.getLogger(__name__) 47 48 ANDROIDPUBLISHER = "https://www.googleapis.com/auth/androidpublisher" 49 API = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications" 50 PACKAGE = "com.honjimaku.subrep" 51 NOT_SET_UP = "Google Play purchases are not set up on this server." 52 NO_ANSWER = "Google Play did not answer. Try again later." 53 # purchases.products.get numbers the states of a purchase in another way than 54 # the Android library, where PURCHASED is 1. Do not mix the two. 55 PURCHASED = 0 # purchaseState 56 CONSUMED = 1 # consumptionState 57 TEST = 0 # purchaseType: a license tester bought it, and paid nothing 58 59 60 class PlayError(Exception): 61 """A purchase that gives no hours now. `status` is the HTTP answer for the app.""" 62 63 def __init__(self, status: int, detail: str): 64 super().__init__(detail) 65 self.status = status 66 self.detail = detail 67 68 69 class PlayPurchase(Base): 70 """Ledger of Google Play purchases. 71 72 The unique token hash makes the credit idempotent. The app sends a purchase 73 again after a crash or a lost answer, and the hours must come only once. 74 Only the hash is kept: no code needs the token after the credit. 75 """ 76 77 __tablename__ = "play_purchases" 78 79 id: Mapped[str] = mapped_column( 80 String(64), primary_key=True, default=lambda: new_id("play") 81 ) 82 token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) 83 account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id"), index=True) 84 product_id: Mapped[str] = mapped_column(String(32)) 85 quantity: Mapped[int] = mapped_column(Integer, default=1) 86 seconds: Mapped[int] = mapped_column(Integer) 87 test: Mapped[bool] = mapped_column(Boolean, default=False) 88 # The Google order number on the receipt of the buyer (GPA....). Support 89 # finds the account with it. A purchase with a promo code has none. 90 order_id: Mapped[str | None] = mapped_column(String(64), nullable=True) 91 created_at: Mapped[datetime] = mapped_column( 92 DateTime(timezone=True), default=utcnow 93 ) 94 # The time when this server took the hours back after a refund or a chargeback. 95 voided_at: Mapped[datetime | None] = mapped_column( 96 DateTime(timezone=True), nullable=True 97 ) 98 99 100 def _hash(token: str) -> str: 101 return hashlib.sha256(token.encode()).hexdigest() 102 103 104 # --------------------------------------------------------------------------- 105 # the Google Play Developer API 106 # --------------------------------------------------------------------------- 107 108 @functools.lru_cache(maxsize=1) 109 def _credentials(path: str): 110 """Read the key file once. A missing or bad file is PlayError 503.""" 111 from google.auth import exceptions 112 from google.oauth2 import service_account 113 114 try: 115 return service_account.Credentials.from_service_account_file( 116 path, scopes=[ANDROIDPUBLISHER] 117 ) 118 except (OSError, ValueError, exceptions.DefaultCredentialsError) as exc: 119 # Log only the type: the message can hold parts of the file. 120 log.error("cannot read the Google Play key file %s: %s", path, type(exc).__name__) 121 raise PlayError(503, NOT_SET_UP) from exc 122 123 124 def _session(): 125 """An HTTP session that signs each request. Tests replace this function.""" 126 from google.auth.transport.requests import AuthorizedSession 127 128 return AuthorizedSession(_credentials(settings.play_service_account_file)) 129 130 131 def _url(product_id: str, token: str) -> str: 132 return (f"{API}/{PACKAGE}/purchases/products/{quote(product_id, safe='')}" 133 f"/tokens/{quote(token, safe='')}") 134 135 136 def _call(method: str, url: str): 137 """Send one request to Google. Never log `url`: it can hold a purchase token.""" 138 import requests 139 from google.auth import exceptions 140 141 try: 142 with _session() as http: 143 response = http.request(method, url, timeout=15) 144 except exceptions.RefreshError as exc: 145 # A retryable error is a temporary failure of the Google token server, 146 # for example 503 after the retries of google-auth. The key can be good. 147 if exc.retryable: 148 raise PlayError(502, NO_ANSWER) from exc 149 log.error("Google refused the Play key: %s", type(exc).__name__) 150 raise PlayError(503, NOT_SET_UP) from exc 151 except (requests.RequestException, exceptions.TransportError) as exc: 152 raise PlayError(502, NO_ANSWER) from exc 153 if response.status_code in (200, 204): 154 return response 155 if response.status_code in (400, 404, 410): 156 raise PlayError(400, "Google Play does not know this purchase.") 157 if response.status_code in (401, 403): 158 log.error("the Play key has no access to the orders of %s (%d)", 159 PACKAGE, response.status_code) 160 raise PlayError(503, NOT_SET_UP) 161 raise PlayError(502, NO_ANSWER) 162 163 164 def get(product_id: str, token: str) -> dict: 165 """The purchase as Google sees it (purchases.products.get).""" 166 return _call("GET", _url(product_id, token)).json() 167 168 169 def consume(product_id: str, token: str) -> None: 170 """Close the purchase, so that the user can buy the pack again.""" 171 _call("POST", _url(product_id, token) + ":consume") 172 173 174 def voided() -> list[str]: 175 """The tokens of the in-app purchases that Google voided in the last 30 days. 176 177 A refund or a chargeback voids a purchase. 30 days is the default and the 178 longest time of the API, so a missed hour loses nothing. 179 """ 180 tokens: list[str] = [] 181 page = "" 182 while True: 183 url = f"{API}/{PACKAGE}/purchases/voidedpurchases" 184 if page: 185 url += f"?token={quote(page, safe='')}" 186 body = _call("GET", url).json() 187 tokens += [v["purchaseToken"] for v in body.get("voidedPurchases", [])] 188 page = (body.get("tokenPagination") or {}).get("nextPageToken", "") 189 if not page: 190 return tokens 191 192 193 # --------------------------------------------------------------------------- 194 # the credit 195 # --------------------------------------------------------------------------- 196 197 def credit(session: Session, account: Account, product_id: str, token: str, 198 purchase: dict) -> bool: 199 """Add the hours of one purchase that Google returned, once for each token. 200 201 Return True when this call added them, and False when an earlier call did. 202 Raise PlayError when the purchase gives no hours. 203 """ 204 if purchase.get("purchaseState") != PURCHASED: 205 raise PlayError(409, "The purchase is not complete yet.") 206 if purchase.get("productId", product_id) != product_id: 207 raise PlayError(400, "Google Play does not know this purchase.") 208 owner_id = purchase.get("obfuscatedExternalAccountId") 209 # A purchase made outside the app, for example with a promo code, has no 210 # account id. Google says that the app can give it to the user who sends it. 211 if owner_id: 212 owner = session.get(Account, owner_id) 213 if owner is None or accounts.resolve(session, owner).id != account.id: 214 raise PlayError(403, "This purchase belongs to another account.") 215 quantity = int(purchase.get("quantity") or 1) 216 seconds = captions.HOURS[product_id] * 3600 * quantity 217 test = purchase.get("purchaseType") == TEST 218 session.add(PlayPurchase( 219 token_hash=_hash(token), account_id=account.id, product_id=product_id, 220 quantity=quantity, seconds=seconds, test=test, order_id=purchase.get("orderId"), 221 )) 222 try: 223 session.flush() 224 except IntegrityError: 225 session.rollback() # An earlier call added this token. 226 return False 227 if purchase.get("consumptionState") == CONSUMED: 228 # Only this server consumes, and it commits before it consumes. So a 229 # consumed token without a row means that the ledger lost the row. 230 session.rollback() 231 log.warning("consumed Play purchase %s has no ledger row", 232 purchase.get("orderId") or "(no order id)") 233 raise PlayError(409, "This purchase was used already.") 234 captions.add(session, account.id, seconds) 235 session.commit() 236 log.info("play purchase %s: %s x%d for %s%s", 237 purchase.get("orderId") or "(no order id)", product_id, quantity, 238 account.id, " (test)" if test else "") 239 return True 240 241 242 # --------------------------------------------------------------------------- 243 # refunds 244 # --------------------------------------------------------------------------- 245 246 def take_back(session: Session, tokens: list[str]) -> int: 247 """Take back the hours of each voided purchase in `tokens`, once. 248 249 The hours come from the account that has them now, after any merge. A 250 balance never goes below zero, so the hours that the buyer used are lost. 251 Return the number of purchases that this call took back. 252 """ 253 hashes = {_hash(t) for t in tokens} 254 if not hashes: 255 return 0 256 rows = session.scalars(select(PlayPurchase).where( 257 PlayPurchase.token_hash.in_(hashes), PlayPurchase.voided_at.is_(None))).all() 258 balance = captions.CaptionBalance 259 taken = 0 260 for row in rows: 261 # Mark the row only if no other pass marked it first. Then two passes 262 # at the same time take the hours back once. 263 marked = session.execute( 264 update(PlayPurchase) 265 .where(PlayPurchase.id == row.id, PlayPurchase.voided_at.is_(None)) 266 .values(voided_at=utcnow()) 267 ).rowcount 268 if marked != 1: 269 continue 270 owner = accounts.resolve(session, session.get(Account, row.account_id)) 271 # One UPDATE, like captions.spend: a credit or a spend at the same time 272 # cannot write over it. 273 session.execute( 274 update(balance) 275 .where(balance.account_id == owner.id) 276 .values(seconds_left=case( 277 (balance.seconds_left > row.seconds, balance.seconds_left - row.seconds), 278 else_=0)) 279 ) 280 taken += 1 281 log.info("voided play purchase %s: took back up to %d s from %s", 282 row.order_id or "(no order id)", row.seconds, owner.id) 283 session.commit() 284 return taken 285 286 287 def take_back_voided(session: Session) -> None: 288 """One pass of main.housekeeping. A failure waits for the next hour.""" 289 try: 290 take_back(session, voided()) 291 except PlayError as exc: 292 log.warning("could not read the voided Play purchases: %s", exc.detail) 293 294 295 # --------------------------------------------------------------------------- 296 # routes 297 # --------------------------------------------------------------------------- 298 299 router = APIRouter(prefix="/api/captions") 300 301 302 class PlayIn(BaseModel): 303 product_id: str = Field(max_length=64) 304 purchase_token: str = Field(min_length=1, max_length=4096) 305 306 307 @router.post("/play-purchase") 308 def play_purchase( 309 body: PlayIn, 310 account: Annotated[Account, Depends(get_account)], 311 session: Annotated[Session, Depends(get_session)], 312 ): 313 """The Play build sends each purchase here, once or more. It gives hours once.""" 314 if not settings.play_configured: 315 raise HTTPException(503, NOT_SET_UP) 316 if body.product_id not in captions.HOURS: 317 raise HTTPException(404, "No such pack.") 318 try: 319 purchase = get(body.product_id, body.purchase_token) 320 credit(session, account, body.product_id, body.purchase_token, purchase) 321 except PlayError as exc: 322 raise HTTPException(exc.status, exc.detail) from exc 323 if purchase.get("consumptionState") != CONSUMED: 324 try: 325 consume(body.product_id, body.purchase_token) 326 except PlayError as exc: 327 # The hours are in the account. The next send of the app consumes it. 328 log.warning("could not consume a Play purchase of %s: %s", account.id, exc.detail) 329 return {"seconds_left": captions.seconds_left(session, account.id)}