Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 81 additions & 10 deletions inapppy/googleplay.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,32 @@ def _ms_timestamp_expired(ms_timestamp: str) -> bool:

return datetime.datetime.utcfromtimestamp(ms_timestamp_value) < now

@staticmethod
def _parse_iso8601_timestamp(iso_timestamp: str) -> datetime.datetime:
"""Parse ISO 8601 timestamp (e.g., '2024-01-15T10:00:00Z' or '2024-01-15T10:00:00.123456Z')."""
# Remove 'Z' suffix and split timezone offset
cleaned = iso_timestamp.replace("Z", "+00:00")
timestamp_part = cleaned.split("+")[0]

# Parse with or without microseconds
if "." in timestamp_part:
dt = datetime.datetime.strptime(timestamp_part, "%Y-%m-%dT%H:%M:%S.%f")
else:
dt = datetime.datetime.strptime(timestamp_part, "%Y-%m-%dT%H:%M:%S")

return dt.replace(tzinfo=datetime.timezone.utc)

@staticmethod
def _iso_timestamp_expired(iso_timestamp: str) -> bool:
"""Check if an ISO 8601 timestamp is expired."""
try:
expiry_dt = GooglePlayVerifier._parse_iso8601_timestamp(iso_timestamp)
now = datetime.datetime.now(datetime.timezone.utc)
return expiry_dt < now
except (ValueError, AttributeError):
# If parsing fails, don't treat as expired
return False

@staticmethod
def _create_credentials(play_console_credentials: Union[str, dict], scope_str: str):
# If str, assume it's a filepath
Expand Down Expand Up @@ -212,15 +238,34 @@ def verify(self, purchase_token: str, product_sku: str, is_subscription: bool =

if is_subscription:
result = self.check_purchase_subscription(purchase_token, product_sku, service)
cancel_reason = int(result.get("cancelReason", 0))

if cancel_reason != 0:
# Check cancellation status
# For old API (v1): check if cancelReason field exists (any value means canceled)
# For new API (v2): check if canceledStateContext exists or subscriptionState is CANCELED
if "cancelReason" in result:
# Old API: presence of cancelReason field means subscription is canceled
raise GoogleError("Subscription is canceled", result)
elif result.get("subscriptionState") == "SUBSCRIPTION_STATE_CANCELED":
# New API (v2): check subscription state
raise GoogleError("Subscription is canceled", result)
elif result.get("canceledStateContext") is not None:
# New API (v2): check canceled state context
raise GoogleError("Subscription is canceled", result)

# Check expiry status
# For old API (v1): expiryTimeMillis in root
# For new API (v2): lineItems[0].expiryTime in ISO format
ms_timestamp = result.get("expiryTimeMillis", 0)

if self._ms_timestamp_expired(ms_timestamp):
raise GoogleError("Subscription expired", result)
if ms_timestamp:
# Old API format with milliseconds timestamp
if self._ms_timestamp_expired(ms_timestamp):
raise GoogleError("Subscription expired", result)
else:
# New API format: check lineItems for ISO 8601 timestamp
line_items = result.get("lineItems", [])
if line_items and "expiryTime" in line_items[0]:
if self._iso_timestamp_expired(line_items[0]["expiryTime"]):
raise GoogleError("Subscription expired", result)
else:
result = self.check_purchase_product(purchase_token, product_sku, service)
purchase_state = int(result.get("purchaseState", 1))
Expand All @@ -242,13 +287,39 @@ def verify_with_result(
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:
# Check cancellation status
# For old API (v1): check if cancelReason field exists (any value means canceled)
# For new API (v2): check if canceledStateContext exists or subscriptionState is CANCELED
if "cancelReason" in result:
# Old API: presence of cancelReason field means subscription is canceled
# (values: 0=user, 1=system, 2=replaced, 3=developer)
verification_result.is_canceled = True
elif result.get("subscriptionState") == "SUBSCRIPTION_STATE_CANCELED":
# New API (v2): check subscription state
verification_result.is_canceled = True
elif result.get("canceledStateContext") is not None:
# New API (v2): check canceled state context
verification_result.is_canceled = True

ms_timestamp = result.get("expiryTimeMillis", 0)
if self._ms_timestamp_expired(ms_timestamp):
verification_result.is_expired = True
# Check expiry status
# For old API (v1): expiryTimeMillis in root (0 or missing means expired)
# For new API (v2): lineItems[0].expiryTime in ISO format
ms_timestamp = result.get("expiryTimeMillis")
if ms_timestamp is not None:
# Old API format: use existing _ms_timestamp_expired logic
# (treats 0 or missing as expired for backward compatibility)
if self._ms_timestamp_expired(ms_timestamp):
verification_result.is_expired = True
else:
# New API format: check lineItems for ISO 8601 timestamp
line_items = result.get("lineItems", [])
if line_items and "expiryTime" in line_items[0]:
if self._iso_timestamp_expired(line_items[0]["expiryTime"]):
verification_result.is_expired = True
elif not line_items:
# No lineItems either - for backward compatibility with old API,
# treat missing expiry info as expired
verification_result.is_expired = True
else:
result = self.check_purchase_product(purchase_token, product_sku, service)
verification_result.raw_response = result
Expand Down
24 changes: 22 additions & 2 deletions tests/test_google_verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,16 @@ def test_google_verify_subscription():
with pytest.raises(errors.GoogleError):
verifier.verify("test-token", "test-product", is_subscription=True)

# canceled
# canceled - non-zero cancelReason
with patch.object(verifier, "check_purchase_subscription", return_value={"cancelReason": 666}):
with pytest.raises(errors.GoogleError):
verifier.verify("test-token", "test-product", is_subscription=True)

# canceled - user canceled (cancelReason = 0)
with patch.object(verifier, "check_purchase_subscription", return_value={"cancelReason": 0}):
with pytest.raises(errors.GoogleError):
verifier.verify("test-token", "test-product", is_subscription=True)

# norm
now = datetime.datetime.utcnow().timestamp()
with patch.object(
Expand All @@ -50,7 +55,7 @@ def test_google_verify_with_result_subscription():
"is_canceled=False)"
)

# canceled
# canceled - non-zero cancelReason
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
Expand All @@ -63,6 +68,21 @@ def test_google_verify_with_result_subscription():
"is_canceled=True)"
)

# canceled - user canceled (cancelReason = 0)
# This is the fix for issue #66: cancelReason=0 means user canceled, not "not canceled"
with patch.object(verifier, "check_purchase_subscription", return_value={"cancelReason": 0}):
result = verifier.verify_with_result("test-token", "test-product", is_subscription=True)
assert result.is_canceled
# Note: is_expired=True because expiryTimeMillis is missing (backward compatibility)
assert result.is_expired
assert result.raw_response == {"cancelReason": 0}
assert (
str(result) == "GoogleVerificationResult("
"raw_response={'cancelReason': 0}, "
"is_expired=True, "
"is_canceled=True)"
)

# norm
now = datetime.datetime.utcnow().timestamp()
exp_value = now * 1000 + 10**10
Expand Down
Loading