commit 8218ba7cc9c26469f2d556efc2deb76fa428cb7f equwal <truex@equwal.com> 2026-09-26 15:10:44 -0700 Take back the hours of refunded Google Play purchases A Google Play buyer can get a refund from Google without the owner. Each hour, main.housekeeping now reads the voided purchases of the last 30 days (purchases.voidedpurchases.list, all pages). For each voided token in play_purchases, it takes the hours of the purchase from the account that has them now, after any merge, and sets voided_at. A balance never goes below zero. A conditional UPDATE (voided_at IS NULL) marks the row, so two passes at the same time take the hours back once. One UPDATE changes the balance, as in captions.spend, so that a credit or a spend at the same time is not lost. Housekeeping calls Google only when the key file is set. A failure of Google gives one warning, and the next pass tries again. Tests: pytest 243 passed (7 new), with a property test for the take-back. The new tests failed before the change. Each of five changes to the new code makes a test fail. test_vendor_files_may_be_kept fails before and after the change, because a new worktree has no frontend/vendor.
backend/main.py | 6 ++- backend/play.py | 91 ++++++++++++++++++++++++++++++- tests/test_play.py | 156 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 248 insertions(+), 5 deletions(-)
diff --git a/backend/main.py b/backend/main.py index 859a742..20911ff 100644 --- a/backend/main.py +++ b/backend/main.py @@ -104,7 +104,8 @@ async def lifespan(_: FastAPI): def housekeeping() -> None: - """One pass of the hourly housekeeping: let go of abandoned browser jobs. + """One pass of the hourly housekeeping: let go of abandoned browser jobs, + and take back the hours of Google Play purchases that Google refunded. It deletes no file. The server keeps each upload and each result for debugging, and the operator deletes them by hand. @@ -113,6 +114,9 @@ def housekeeping() -> None: with SessionLocal() as s: expire_stale_local_jobs(s) + if settings.play_configured: + with SessionLocal() as s: + play.take_back_voided(s) async def _housekeeping() -> None: diff --git a/backend/play.py b/backend/play.py index a439395..e8ef6ca 100644 --- a/backend/play.py +++ b/backend/play.py @@ -9,6 +9,10 @@ Google for the purchase, adds the hours of the pack to the account once, and consumes the purchase, so that the user can buy the pack again. The app never consumes. If this server does not answer, the app sends the purchase again at its next start. + +A buyer can get a refund from Google without the owner. So each hour, +main.housekeeping asks Google for the purchases that it refunded or charged +back, and this module takes their hours back once. """ from __future__ import annotations @@ -21,7 +25,16 @@ from urllib.parse import quote from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field -from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String +from sqlalchemy import ( + Boolean, + DateTime, + ForeignKey, + Integer, + String, + case, + select, + update, +) from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Mapped, Session, mapped_column @@ -78,6 +91,10 @@ class PlayPurchase(Base): created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=utcnow ) + # The time when this server took the hours back after a refund or a chargeback. + voided_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) def _hash(token: str) -> str: @@ -150,6 +167,25 @@ def consume(product_id: str, token: str) -> None: _call("POST", _url(product_id, token) + ":consume") +def voided() -> list[str]: + """The tokens of the in-app purchases that Google voided in the last 30 days. + + A refund or a chargeback voids a purchase. 30 days is the default and the + longest time of the API, so a missed hour loses nothing. + """ + tokens: list[str] = [] + page = "" + while True: + url = f"{API}/{PACKAGE}/purchases/voidedpurchases" + if page: + url += f"?token={quote(page, safe='')}" + body = _call("GET", url).json() + tokens += [v["purchaseToken"] for v in body.get("voidedPurchases", [])] + page = (body.get("tokenPagination") or {}).get("nextPageToken", "") + if not page: + return tokens + + # --------------------------------------------------------------------------- # the credit # --------------------------------------------------------------------------- @@ -199,6 +235,59 @@ def credit(session: Session, account: Account, product_id: str, token: str, return True +# --------------------------------------------------------------------------- +# refunds +# --------------------------------------------------------------------------- + +def take_back(session: Session, tokens: list[str]) -> int: + """Take back the hours of each voided purchase in `tokens`, once. + + The hours come from the account that has them now, after any merge. A + balance never goes below zero, so the hours that the buyer used are lost. + Return the number of purchases that this call took back. + """ + hashes = {_hash(t) for t in tokens} + if not hashes: + return 0 + rows = session.scalars(select(PlayPurchase).where( + PlayPurchase.token_hash.in_(hashes), PlayPurchase.voided_at.is_(None))).all() + balance = captions.CaptionBalance + taken = 0 + for row in rows: + # Mark the row only if no other pass marked it first. Then two passes + # at the same time take the hours back once. + marked = session.execute( + update(PlayPurchase) + .where(PlayPurchase.id == row.id, PlayPurchase.voided_at.is_(None)) + .values(voided_at=utcnow()) + ).rowcount + if marked != 1: + continue + owner = accounts.resolve(session, session.get(Account, row.account_id)) + # One UPDATE, like captions.spend: a credit or a spend at the same time + # cannot write over it. + session.execute( + update(balance) + .where(balance.account_id == owner.id) + .values(seconds_left=case( + (balance.seconds_left > row.seconds, balance.seconds_left - row.seconds), + else_=0)) + ) + taken += 1 + log.info("voided play purchase %s: took back up to %d s from %s", + row.order_id or "(no order id)", row.seconds, owner.id) + session.commit() + return taken + + +def take_back_voided(session: Session) -> None: + """One pass of main.housekeeping. A failure waits for the next hour.""" + try: + take_back(session, voided()) + except PlayError as exc: + log.warning("could not read the voided Play purchases: %s", exc.detail) + + # --------------------------------------------------------------------------- # routes # --------------------------------------------------------------------------- diff --git a/tests/test_play.py b/tests/test_play.py index 0001c47..5c0e3e5 100644 --- a/tests/test_play.py +++ b/tests/test_play.py @@ -1,7 +1,7 @@ """Google Play purchases of cloud caption hours: the Play build of Subrep. -No test reaches Google. FakeGooglePlay replaces play.get and play.consume, and -the tests of the HTTP layer give play._session a fake session. +No test reaches Google. FakeGooglePlay replaces play.get, play.consume and +play.voided, and the tests of the HTTP layer give play._session a fake session. """ from __future__ import annotations @@ -19,7 +19,7 @@ from hypothesis import given from hypothesis import settings as hsettings from hypothesis import strategies as st -from backend import accounts, captions, play +from backend import accounts, captions, main, play from backend.db import Account, SessionLocal from backend.settings import settings @@ -39,6 +39,8 @@ class FakeGooglePlay: self.gets: list[tuple[str, str]] = [] self.down = False self.fail_consume = 0 + # The tokens of the purchases that Google refunded or charged back. + self.voided_tokens: list[str] = [] def buy(self, token: str, account: str | None, product: str = "captions20", state: int = 0, quantity: int | None = None, test: bool = False) -> dict: @@ -74,6 +76,9 @@ class FakeGooglePlay: self.consumed.append(token) self.purchases[token]["consumptionState"] = 1 + def voided(self) -> list[str]: + return list(self.voided_tokens) + @pytest.fixture(autouse=True) def _no_google(monkeypatch): @@ -90,6 +95,7 @@ def google(monkeypatch, tmp_path): fake = FakeGooglePlay() monkeypatch.setattr(play, "get", fake.get) monkeypatch.setattr(play, "consume", fake.consume) + monkeypatch.setattr(play, "voided", fake.voided) key = tmp_path / "play-key.json" key.write_text("{}") # play.get is fake, so nothing reads the key. monkeypatch.setattr(settings, "play_service_account_file", str(key)) @@ -425,6 +431,123 @@ def test_a_bad_key_file_is_503(tmp_path, content): assert caught.value.status == 503 +def test_the_voided_list_reads_every_page(http): + http.answers = [ + FakeResponse(200, {"voidedPurchases": [{"purchaseToken": "a", "orderId": "GPA.1"}], + "tokenPagination": {"nextPageToken": "p2"}}), + FakeResponse(200, {"voidedPurchases": [{"purchaseToken": "b", "orderId": "GPA.2"}]}), + FakeResponse(200, {}), + ] + assert play.voided() == ["a", "b"] + assert play.voided() == [] # No refunds in the last 30 days. + url = ("https://androidpublisher.googleapis.com/androidpublisher/v3/applications/" + "com.honjimaku.subrep/purchases/voidedpurchases") + assert http.seen == [("GET", url), ("GET", url + "?token=p2"), ("GET", url)] + + +# --- refunds ------------------------------------------------------------------ +# These tests use SessionLocal and no TestClient: each TestClient starts a pass +# of the housekeeping in the background. + +def paid(product: str = "captions20", account: str | None = None) -> dict: + """A paid purchase that nobody consumed yet, as Google returns it.""" + purchase = {"purchaseState": 0, "consumptionState": 0, "productId": product} + if account is not None: + purchase["obfuscatedExternalAccountId"] = account + return purchase + + +def test_a_refund_takes_back_the_hours_once(): + token = new_token() + with SessionLocal() as s: + me = accounts.new_account(s) + captions.add(s, me.id, 5 * HOUR) # Hours from an earlier purchase. + s.commit() + assert play.credit(s, me, "captions20", token, paid(account=me.id)) + assert play.take_back(s, [token]) == 1 + assert play.take_back(s, [token]) == 0 + assert captions.seconds_left(s, me.id) == 5 * HOUR + [row] = s.query(play.PlayPurchase).filter_by(token_hash=play._hash(token)).all() + assert row.voided_at is not None + + +def test_two_passes_at_the_same_time_take_the_hours_back_once(monkeypatch): + token = new_token() + with SessionLocal() as s, SessionLocal() as other: + me = accounts.new_account(s) + captions.add(s, me.id, 5 * HOUR) + s.commit() + assert play.credit(s, me, "captions20", token, paid(account=me.id)) + # The other pass runs after this pass found the row, and before it + # marks the row. + other_pass = [] + execute = s.execute + + def execute_after_the_other_pass(*args, **kwargs): + if not other_pass: + other_pass.append(play.take_back(other, [token])) + return execute(*args, **kwargs) + + monkeypatch.setattr(s, "execute", execute_after_the_other_pass) + assert play.take_back(s, [token]) == 0 + assert other_pass == [1] + me_id = me.id + with SessionLocal() as s: + assert captions.seconds_left(s, me_id) == 5 * HOUR + + +def test_a_refund_after_a_merge_takes_the_hours_from_the_survivor(): + token = new_token() + with SessionLocal() as s: + old, survivor = accounts.new_account(s), accounts.new_account(s) + s.commit() + assert play.credit(s, old, "captions20", token, paid(account=old.id)) + accounts.merge(s, old, survivor) + s.commit() + assert captions.seconds_left(s, survivor.id) == 20 * HOUR + assert play.take_back(s, [token]) == 1 + assert captions.seconds_left(s, survivor.id) == 0 + assert captions.seconds_left(s, old.id) == 0 + + +def test_housekeeping_takes_back_refunds_only_when_play_is_set_up(google, monkeypatch): + token = new_token() + with SessionLocal() as s: + me = accounts.new_account(s) + s.commit() + assert play.credit(s, me, "captions20", token, paid(account=me.id)) + me_id = me.id + google.voided_tokens = [token] + asked = [] + + def spy(): + asked.append(True) + return google.voided() + + monkeypatch.setattr(play, "voided", spy) + key = settings.play_service_account_file + monkeypatch.setattr(settings, "play_service_account_file", "") + main.housekeeping() + assert asked == [] + + monkeypatch.setattr(settings, "play_service_account_file", key) + main.housekeeping() + assert asked == [True] + with SessionLocal() as s: + assert captions.seconds_left(s, me_id) == 0 + + +def test_a_google_failure_does_not_stop_housekeeping(google, monkeypatch, caplog): + def down(): + raise play.PlayError(502, play.NO_ANSWER) + + monkeypatch.setattr(play, "voided", down) + main.housekeeping() + warnings = [record for record in caplog.records + if record.name == "backend.play" and record.levelno == logging.WARNING] + assert len(warnings) == 1 and play.NO_ANSWER in warnings[0].getMessage() + + # --- properties --------------------------------------------------------------- @hsettings(max_examples=50, deadline=None) @@ -464,6 +587,33 @@ def test_a_token_gives_its_hours_once_and_only_when_paid_for_this_account(buys, assert s.query(play.PlayPurchase).filter_by(account_id=ids["me"]).count() == len(expected) +@hsettings(max_examples=50, deadline=None) +@given(bought=st.lists(st.sampled_from(sorted(captions.HOURS)), min_size=1, max_size=4), + spent=st.integers(0, 300 * HOUR), + voids=st.lists(st.lists(st.integers(0, 5), max_size=6), max_size=4)) +def test_a_refund_takes_back_the_hours_once_and_never_below_zero(bought, spent, voids): + run = secrets.token_hex(6) # The database lives across examples. + with SessionLocal() as s: + me = accounts.new_account(s) + s.commit() + for i, product in enumerate(bought): + assert play.credit(s, me, product, f"{run}.{i}", paid(product, me.id)) + total = sum(captions.HOURS[product] * HOUR for product in bought) + used = min(spent, total) + assert captions.spend(s, me.id, used) + voided: set[int] = set() + taken = 0 + for indexes in voids: # An index past the purchases is a token that this server never saw. + taken += play.take_back(s, [f"{run}.{i}" for i in indexes]) + voided |= {i for i in indexes if i < len(bought)} + back = sum(captions.HOURS[bought[i]] * HOUR for i in voided) + assert captions.seconds_left(s, me.id) == max(0, total - used - back) + assert taken == len(voided) + assert s.query(play.PlayPurchase).filter( + play.PlayPurchase.account_id == me.id, + play.PlayPurchase.voided_at.is_not(None)).count() == len(voided) + + @given(st.text(min_size=1)) def test_any_token_is_one_path_segment_of_the_address(token): url = play._url("captions20", token)