Recently Written · git

subplz-web

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

Log | Files | Refs


commit eb1de8fd7fe8520daef6c7af433c6804a1d1249e
equwal <truex@equwal.com>
2026-09-26 14:44:09 -0700

Show whether the server can check Google Play purchases

GET /api/captions has a new field, play_available. It is true when the
caption key is set and the file in SUBPLZ_WEB_PLAY_SERVICE_ACCOUNT_FILE
exists. The Play build of Subrep reads this field. The web build still
reads available, which needs the Stripe key.

The test harness clears the new variable, so that no test uses a real key
from a .env file.

Tests: pytest 198 passed (1 new). The new test failed before the change.
test_vendor_files_may_be_kept fails before and after the change, because
a new worktree has no frontend/vendor.

 .env.example        |  7 +++++++
 backend/captions.py |  2 ++
 backend/settings.py | 12 ++++++++++++
 tests/conftest.py   |  2 ++
 tests/test_play.py  | 26 ++++++++++++++++++++++++++
 5 files changed, 49 insertions(+)
diff --git a/.env.example b/.env.example
index 585b360..22041f2 100644
--- a/.env.example
+++ b/.env.example
@@ -99,5 +99,12 @@ SUBPLZ_WEB_CONTACT_EMAIL=
 # key for the desktop build. Without it, Subrep Pro is not for sale.
 # SUBPLZ_WEB_SUBREP_LICENSE_KEY=
 
+# --- Google Play (Subrep Play build) ------------------------------------------
+# The path of the JSON key of a service account that can read and consume the
+# orders of Subrep in Play Console. Keep the file outside the checkout, owned by
+# the service user, with mode 600. Do not put the key itself in this file.
+# Without the file, the Play build of Subrep sells no hours.
+# SUBPLZ_WEB_PLAY_SERVICE_ACCOUNT_FILE=/etc/subplz-web/play-service-account.json
+
 # --- uploads ---------------------------------------------------------------
 SUBPLZ_WEB_MAX_UPLOAD_BYTES=2147483648
diff --git a/backend/captions.py b/backend/captions.py
index da43c0d..27305b3 100644
--- a/backend/captions.py
+++ b/backend/captions.py
@@ -193,6 +193,8 @@ def _state(session: Session, account: Account) -> dict:
         "account_id": account.id,
         "seconds_left": seconds_left(session, account.id),
         "available": bool(settings.caption_api_key) and settings.payments_configured,
+        # The Play build of Subrep sells with Google Play Billing, not with Stripe.
+        "play_available": bool(settings.caption_api_key) and settings.play_configured,
         "packs": [
             {"id": p.id, "name": p.name, "hours": HOURS[p.id],
              "price_display": p.price_display}
diff --git a/backend/settings.py b/backend/settings.py
index 173da8e..201dd42 100644
--- a/backend/settings.py
+++ b/backend/settings.py
@@ -141,6 +141,13 @@ class Settings(BaseSettings):
     # The longest piece of speech in one request. The app sends 11 s at most.
     caption_max_seconds: int = 30
 
+    # --- Google Play (Subrep Play build) ------------------------------------
+    # The path of the JSON key of a service account that can read and consume
+    # the orders of Subrep in Play Console. Keep the file outside the checkout,
+    # owned by the service user, with mode 600. Without the file, the server
+    # tells the Play build that it cannot sell hours (play_available).
+    play_service_account_file: str = ""
+
     # --- Subrep Pro -------------------------------------------------------
     # The Ed25519 key that signs the licences of the Subrep desktop app
     # (base64url of the 32-byte private key). Without it, Subrep Pro is not
@@ -160,6 +167,11 @@ class Settings(BaseSettings):
     def payments_configured(self) -> bool:
         return bool(self.stripe_secret_key)
 
+    @property
+    def play_configured(self) -> bool:
+        path = self.play_service_account_file
+        return bool(path) and Path(path).is_file()
+
     @property
     def email_configured(self) -> bool:
         return bool(self.smtp_host)
diff --git a/tests/conftest.py b/tests/conftest.py
index 9b362ea..f31c815 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -23,6 +23,8 @@ os.environ.update(
     SUBPLZ_WEB_PUBLIC_BASE_URL="https://example.test",
     SUBPLZ_WEB_MATCH_CHECK="false",
     SUBPLZ_WEB_SMTP_HOST="",
+    # No test may use a real Google Play key from a .env file.
+    SUBPLZ_WEB_PLAY_SERVICE_ACCOUNT_FILE="",
 )
 
 import pytest  # noqa: E402
diff --git a/tests/test_play.py b/tests/test_play.py
new file mode 100644
index 0000000..162594b
--- /dev/null
+++ b/tests/test_play.py
@@ -0,0 +1,26 @@
+"""Google Play purchases of cloud caption hours: the Play build of Subrep."""
+
+from __future__ import annotations
+
+from backend.settings import settings
+
+
+def test_the_play_build_can_sell_only_when_the_server_can_check_purchases(
+        client, monkeypatch, tmp_path):
+    monkeypatch.setattr(settings, "caption_api_key", "dg_test")
+    monkeypatch.setattr(settings, "stripe_secret_key", "")
+    monkeypatch.setattr(settings, "play_service_account_file", "")
+    assert client.get("/api/captions").json()["play_available"] is False
+
+    key = tmp_path / "play-key.json"
+    monkeypatch.setattr(settings, "play_service_account_file", str(key))
+    assert client.get("/api/captions").json()["play_available"] is False  # no file yet
+
+    key.write_text("{}")
+    state = client.get("/api/captions").json()
+    # The Play build does not need Stripe, and the web build does not need Google.
+    assert state["play_available"] is True
+    assert state["available"] is False
+
+    monkeypatch.setattr(settings, "caption_api_key", "")
+    assert client.get("/api/captions").json()["play_available"] is False