Repository: dotpot/InAppPy

Issue:
cancelled android purchases do not get caught correctly
in lines 109-110 of googleplay.py:

```
            cancel_reason = int(result.get('cancelReason', 0))
            if cancel_reason != 0:
                raise GoogleError('Subscription is canceled', result)

```

If we look at [the docs](https://developers.google.com/android-publisher/api-ref/purchases/subscriptions) we see that a value of `cancelReason = 0` also indicates cancellation by user. Therefore I do not understand what was the original intention of this snippet of code. If we look at the lines following this snippet:

```
            ms_timestamp = result.get('expiryTimeMillis', 0)
            if self._ms_timestamp_expired(ms_timestamp):
                raise GoogleError('Subscription expired', result)
        else:
            result = self.check_purchase_product(purchase_token, product_sku, service)
            purchase_state = int(result.get('purchaseState', 1))

            if purchase_state != 0:
                raise GoogleError('Purchase cancelled', result)

```

I think the general issue here is, that a subscription can be

a) not cancelled and not expired
b) cancelled and not expired
c) cancelled and expired

So even if we fixed the issue with the code above, we would have to rethink how we throw errors / catch google certificate expiry (see also #25).

I suggest we remove the current mechanism of throwing errors an instead return some sort of datastructure containing: `raw_response`, `is_expired`, `is_cancelled`.

That way we describe all 3 scenarios above. What do you think?

Discussion:
deepcode[bot]: [**DeepCode Report (#66d19b)**](https://www.deepcode.ai/app/gh/dotpot/InAppPy/662179362b679dbd3250cc759299bd454842b6dc/dotpot/InAppPy/66d19b0da5effa06d7c609b782192a9f6c1ac53a/_/%2F/code/)
---
DeepCode analyzed this pull request.
There are no new issues. 2 info reports were fixed.

Patch:
diff --git a/README.rst b/README.rst
index 9ea1223..771796a 100644
--- a/README.rst
+++ b/README.rst
@@ -17,10 +17,11 @@ Table of contents
 2. Installation
 3. Google Play (`receipt` + `signature`)
 4. Google Play (verification)
-5. App Store (`receipt` + using optional `shared-secret`)
-6. App Store Response (`validation_result` / `raw_response`) example
-7. App Store, **asyncio** version (available in the inapppy.asyncio package)
-8. Development
+5. Google Play (verification with result)
+6. App Store (`receipt` + using optional `shared-secret`)
+7. App Store Response (`validation_result` / `raw_response`) example
+8. App Store, **asyncio** version (available in the inapppy.asyncio package)
+9. Development
 
 
 1. Introduction
@@ -88,7 +89,42 @@ In-app purchase validation library for `Apple AppStore` and `GooglePlay` (`App S
         return response
 
 
-5. App Store (validates `receipt` using optional `shared-secret` against iTunes service)
+5. Google Play verification (with result)
+=========================================
+Alternative to `.verify` method, instead of raising an error result class will be returned.
+
+.. code:: python
+
+    from inapppy import GooglePlayVerifier, errors
+
+
+    def google_validator(receipt):
+        """
+        Accepts receipt, validates in Google.
+        """
+        purchase_token = receipt['purchaseToken']
+        product_sku = receipt['productId']
+        verifier = GooglePlayVerifier(
+            GOOGLE_BUNDLE_ID,
+            GOOGLE_SERVICE_ACCOUNT_KEY_FILE,
+        )
+        response = {'valid': False, 'transactions': []}
+
+        result = verifier.verify_with_result(
+            purchase_token,
+            product_sku,
+            is_subscription=True
+        )
+
+        # result contains data
+        raw_response = result.raw_response
+        is_canceled = result.is_canceled
+        is_expired = result.is_expired
+
+        return result
+
+
+6. App Store (validates `receipt` using optional `shared-secret` against iTunes service)
 ========================================================================================
 .. code:: python
 
@@ -110,7 +146,7 @@ In-app purchase validation library for `Apple AppStore` and `GooglePlay` (`App S
 
 
 
-6. App Store Response (`validation_result` / `raw_response`) example
+7. App Store Response (`validation_result` / `raw_response`) example
 ====================================================================
 .. code:: json
 
@@ -190,7 +226,7 @@ In-app purchase validation library for `Apple AppStore` and `GooglePlay` (`App S
     }
 
 
-7. App Store, asyncio version (available in the inapppy.asyncio package)
+8. App Store, asyncio version (available in the inapppy.asyncio package)
 ========================================================================
 .. code:: python
 
@@ -213,7 +249,7 @@ In-app purchase validation library for `Apple AppStore` and `GooglePlay` (`App S
 
 
 
-8. Development
+9. Development
 ==============
 
 .. code:: bash
diff --git a/inapppy/errors.py b/inapppy/errors.py
index 6436945..bad7e22 100644
--- a/inapppy/errors.py
+++ b/inapppy/errors.py
@@ -18,5 +18,4 @@ class InAppPyValidationError(Exception):
 
 
 class GoogleError(InAppPyValidationError):
-    def __init__(self, message: str = None, raw_response: dict = None, *args, **kwargs):
-        super().__init__(message, raw_response, *args, **kwargs)
+    pass
diff --git a/inapppy/googleplay.py b/inapppy/googleplay.py
index 25a849c..eee64a8 100644
--- a/inapppy/googleplay.py
+++ b/inapppy/googleplay.py
@@ -72,6 +72,27 @@ class GooglePlayValidator:
             return False
 
 
+class GoogleVerificationResult:
+    """Google verification result class."""
+
+    raw_response: dict = {}
+    is_expired: bool = False
+    is_canceled: bool = False
+
+    def __init__(self, raw_response: dict, is_expired: bool, is_canceled: bool):
+        self.raw_response = raw_response
+        self.is_expired = is_expired
+        self.is_canceled = is_canceled
+
+    def __repr__(self):
+        return (
+            f"GoogleVerificationResult("
+            f"raw_response={self.raw_response}, "
+            f"is_expired={self.is_expired}, "
+            f"is_canceled={self.is_canceled})"
+        )
+
+
 class GooglePlayVerifier:
     def __init__(self, bundle_id: str, private_key_path: str, http_timeout: int = 15) -> None:
         """
@@ -159,3 +180,32 @@ class GooglePlayVerifier:
                 raise GoogleError("Purchase cancelled", result)
 
         return result
+
+    def verify_with_result(
+        self, purchase_token: str, product_sku: str, is_subscription: bool = False
+    ) -> GoogleVerificationResult:
+        """Verifies by returning verification result instead of raising an error,
+        basically it's and better alternative to verify method."""
+        service = build("androidpublisher", "v3", http=self.http)
+        verification_result = GoogleVerificationResult({}, False, False)
+
+        if is_subscription:
+            result = self.check_purchase_subscription(purchase_token, product_sku, service)
+            verification_result.raw_response = result
+
+            cancel_reason = int(result.get("cancelReason", 0))
+            if cancel_reason != 0:
+                verification_result.is_canceled = True
+
+            ms_timestamp = result.get("expiryTimeMillis", 0)
+            if self._ms_timestamp_expired(ms_timestamp):
+                verification_result.is_expired = True
+        else:
+            result = self.check_purchase_product(purchase_token, product_sku, service)
+            verification_result.raw_response = result
+
+            purchase_state = int(result.get("purchaseState", 1))
+            if purchase_state != 0:
+                verification_result.is_canceled = True
+
+        return verification_result


Test patch:
diff --git a/tests/test_google_verifier.py b/tests/test_google_verifier.py
index 0e213a7..ed517e2 100644
--- a/tests/test_google_verifier.py
+++ b/tests/test_google_verifier.py
@@ -32,6 +32,48 @@ def test_google_verify_subscription():
                 verifier.verify("test-token", "test-product", is_subscription=True)
 
 
+def test_google_verify_with_result_subscription():
+    with patch.object(googleplay, "build", return_value=None):
+        with patch.object(googleplay.GooglePlayVerifier, "_authorize", return_value=None):
+            verifier = googleplay.GooglePlayVerifier("test-bundle-id", "private_key_path", 30)
+
+            # expired
+            with patch.object(verifier, "check_purchase_subscription", return_value={"expiryTimeMillis": 666}):
+                result = verifier.verify_with_result("test-token", "test-product", is_subscription=True)
+                assert result.is_canceled is False
+                assert result.is_expired
+                assert result.raw_response == {"expiryTimeMillis": 666}
+                assert (
+                    str(result) == "GoogleVerificationResult(raw_response="
+                    "{'expiryTimeMillis': 666}, "
+                    "is_expired=True, "
+                    "is_canceled=False)"
+                )
+
+            # canceled
+            with patch.object(verifier, "check_purchase_subscription", return_value={"cancelReason": 666}):
+                result = verifier.verify_with_result("test-token", "test-product", is_subscription=True)
+                assert result.is_canceled
+                assert result.is_expired
+                assert result.raw_response == {"cancelReason": 666}
+                assert (
+                    str(result) == "GoogleVerificationResult("
+                    "raw_response={'cancelReason': 666}, "
+                    "is_expired=True, "
+                    "is_canceled=True)"
+                )
+
+            # norm
+            now = datetime.datetime.utcnow().timestamp()
+            exp_value = now * 1000 + 10 ** 10
+            with patch.object(verifier, "check_purchase_subscription", return_value={"expiryTimeMillis": exp_value}):
+                result = verifier.verify_with_result("test-token", "test-product", is_subscription=True)
+                assert result.is_canceled is False
+                assert result.is_expired is False
+                assert result.raw_response == {"expiryTimeMillis": exp_value}
+                assert str(result) is not None
+
+
 def test_google_verify_product():
     with patch.object(googleplay, "build", return_value=None):
         with patch.object(googleplay.GooglePlayVerifier, "_authorize", return_value=None):
@@ -47,6 +89,33 @@ def test_google_verify_product():
                     verifier.verify("test-token", "test-product")
 
 
+def test_google_verify_with_result_product():
+    with patch.object(googleplay, "build", return_value=None):
+        with patch.object(googleplay.GooglePlayVerifier, "_authorize", return_value=None):
+            verifier = googleplay.GooglePlayVerifier("test-bundle-id", "private_key_path", 30)
+
+            # purchase
+            with patch.object(verifier, "check_purchase_product", return_value={"purchaseState": 0}):
+                result = verifier.verify_with_result("test-token", "test-product")
+                assert result.is_canceled is False
+                assert result.is_expired is False
+                assert result.raw_response == {"purchaseState": 0}
+                assert str(result) is not None
+
+            # cancelled
+            with patch.object(verifier, "check_purchase_product", return_value={"purchaseState": 1}):
+                result = verifier.verify_with_result("test-token", "test-product")
+                assert result.is_canceled
+                assert result.is_expired is False
+                assert result.raw_response == {"purchaseState": 1}
+                assert (
+                    str(result) == "GoogleVerificationResult("
+                    "raw_response={'purchaseState': 1}, "
+                    "is_expired=False, "
+                    "is_canceled=True)"
+                )
+
+
 DATA_DIR = os.path.join(os.path.dirname(__file__), "data")