Recently Written · git

subplz-web

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

Log | Files | Refs


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