Skip to content

Commit 14a5af1

Browse files
cx-lukas-salkauskas-xLukas Šalkauskas
authored andcommitted
fix cancelReason interpretation in GooglePlayVerifier
1 parent 3463ab5 commit 14a5af1

File tree

2 files changed

+103
-12
lines changed

2 files changed

+103
-12
lines changed

inapppy/googleplay.py

Lines changed: 81 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,32 @@ def _ms_timestamp_expired(ms_timestamp: str) -> bool:
126126

127127
return datetime.datetime.utcfromtimestamp(ms_timestamp_value) < now
128128

129+
@staticmethod
130+
def _parse_iso8601_timestamp(iso_timestamp: str) -> datetime.datetime:
131+
"""Parse ISO 8601 timestamp (e.g., '2024-01-15T10:00:00Z' or '2024-01-15T10:00:00.123456Z')."""
132+
# Remove 'Z' suffix and split timezone offset
133+
cleaned = iso_timestamp.replace("Z", "+00:00")
134+
timestamp_part = cleaned.split("+")[0]
135+
136+
# Parse with or without microseconds
137+
if "." in timestamp_part:
138+
dt = datetime.datetime.strptime(timestamp_part, "%Y-%m-%dT%H:%M:%S.%f")
139+
else:
140+
dt = datetime.datetime.strptime(timestamp_part, "%Y-%m-%dT%H:%M:%S")
141+
142+
return dt.replace(tzinfo=datetime.timezone.utc)
143+
144+
@staticmethod
145+
def _iso_timestamp_expired(iso_timestamp: str) -> bool:
146+
"""Check if an ISO 8601 timestamp is expired."""
147+
try:
148+
expiry_dt = GooglePlayVerifier._parse_iso8601_timestamp(iso_timestamp)
149+
now = datetime.datetime.now(datetime.timezone.utc)
150+
return expiry_dt < now
151+
except (ValueError, AttributeError):
152+
# If parsing fails, don't treat as expired
153+
return False
154+
129155
@staticmethod
130156
def _create_credentials(play_console_credentials: Union[str, dict], scope_str: str):
131157
# If str, assume it's a filepath
@@ -212,15 +238,34 @@ def verify(self, purchase_token: str, product_sku: str, is_subscription: bool =
212238

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

217-
if cancel_reason != 0:
242+
# Check cancellation status
243+
# For old API (v1): check if cancelReason field exists (any value means canceled)
244+
# For new API (v2): check if canceledStateContext exists or subscriptionState is CANCELED
245+
if "cancelReason" in result:
246+
# Old API: presence of cancelReason field means subscription is canceled
247+
raise GoogleError("Subscription is canceled", result)
248+
elif result.get("subscriptionState") == "SUBSCRIPTION_STATE_CANCELED":
249+
# New API (v2): check subscription state
250+
raise GoogleError("Subscription is canceled", result)
251+
elif result.get("canceledStateContext") is not None:
252+
# New API (v2): check canceled state context
218253
raise GoogleError("Subscription is canceled", result)
219254

255+
# Check expiry status
256+
# For old API (v1): expiryTimeMillis in root
257+
# For new API (v2): lineItems[0].expiryTime in ISO format
220258
ms_timestamp = result.get("expiryTimeMillis", 0)
221-
222-
if self._ms_timestamp_expired(ms_timestamp):
223-
raise GoogleError("Subscription expired", result)
259+
if ms_timestamp:
260+
# Old API format with milliseconds timestamp
261+
if self._ms_timestamp_expired(ms_timestamp):
262+
raise GoogleError("Subscription expired", result)
263+
else:
264+
# New API format: check lineItems for ISO 8601 timestamp
265+
line_items = result.get("lineItems", [])
266+
if line_items and "expiryTime" in line_items[0]:
267+
if self._iso_timestamp_expired(line_items[0]["expiryTime"]):
268+
raise GoogleError("Subscription expired", result)
224269
else:
225270
result = self.check_purchase_product(purchase_token, product_sku, service)
226271
purchase_state = int(result.get("purchaseState", 1))
@@ -242,13 +287,39 @@ def verify_with_result(
242287
result = self.check_purchase_subscription(purchase_token, product_sku, service)
243288
verification_result.raw_response = result
244289

245-
cancel_reason = int(result.get("cancelReason", 0))
246-
if cancel_reason != 0:
290+
# Check cancellation status
291+
# For old API (v1): check if cancelReason field exists (any value means canceled)
292+
# For new API (v2): check if canceledStateContext exists or subscriptionState is CANCELED
293+
if "cancelReason" in result:
294+
# Old API: presence of cancelReason field means subscription is canceled
295+
# (values: 0=user, 1=system, 2=replaced, 3=developer)
296+
verification_result.is_canceled = True
297+
elif result.get("subscriptionState") == "SUBSCRIPTION_STATE_CANCELED":
298+
# New API (v2): check subscription state
299+
verification_result.is_canceled = True
300+
elif result.get("canceledStateContext") is not None:
301+
# New API (v2): check canceled state context
247302
verification_result.is_canceled = True
248303

249-
ms_timestamp = result.get("expiryTimeMillis", 0)
250-
if self._ms_timestamp_expired(ms_timestamp):
251-
verification_result.is_expired = True
304+
# Check expiry status
305+
# For old API (v1): expiryTimeMillis in root (0 or missing means expired)
306+
# For new API (v2): lineItems[0].expiryTime in ISO format
307+
ms_timestamp = result.get("expiryTimeMillis")
308+
if ms_timestamp is not None:
309+
# Old API format: use existing _ms_timestamp_expired logic
310+
# (treats 0 or missing as expired for backward compatibility)
311+
if self._ms_timestamp_expired(ms_timestamp):
312+
verification_result.is_expired = True
313+
else:
314+
# New API format: check lineItems for ISO 8601 timestamp
315+
line_items = result.get("lineItems", [])
316+
if line_items and "expiryTime" in line_items[0]:
317+
if self._iso_timestamp_expired(line_items[0]["expiryTime"]):
318+
verification_result.is_expired = True
319+
elif not line_items:
320+
# No lineItems either - for backward compatibility with old API,
321+
# treat missing expiry info as expired
322+
verification_result.is_expired = True
252323
else:
253324
result = self.check_purchase_product(purchase_token, product_sku, service)
254325
verification_result.raw_response = result

tests/test_google_verifier.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,16 @@ def test_google_verify_subscription():
1919
with pytest.raises(errors.GoogleError):
2020
verifier.verify("test-token", "test-product", is_subscription=True)
2121

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

27+
# canceled - user canceled (cancelReason = 0)
28+
with patch.object(verifier, "check_purchase_subscription", return_value={"cancelReason": 0}):
29+
with pytest.raises(errors.GoogleError):
30+
verifier.verify("test-token", "test-product", is_subscription=True)
31+
2732
# norm
2833
now = datetime.datetime.utcnow().timestamp()
2934
with patch.object(
@@ -50,7 +55,7 @@ def test_google_verify_with_result_subscription():
5055
"is_canceled=False)"
5156
)
5257

53-
# canceled
58+
# canceled - non-zero cancelReason
5459
with patch.object(verifier, "check_purchase_subscription", return_value={"cancelReason": 666}):
5560
result = verifier.verify_with_result("test-token", "test-product", is_subscription=True)
5661
assert result.is_canceled
@@ -63,6 +68,21 @@ def test_google_verify_with_result_subscription():
6368
"is_canceled=True)"
6469
)
6570

71+
# canceled - user canceled (cancelReason = 0)
72+
# This is the fix for issue #66: cancelReason=0 means user canceled, not "not canceled"
73+
with patch.object(verifier, "check_purchase_subscription", return_value={"cancelReason": 0}):
74+
result = verifier.verify_with_result("test-token", "test-product", is_subscription=True)
75+
assert result.is_canceled
76+
# Note: is_expired=True because expiryTimeMillis is missing (backward compatibility)
77+
assert result.is_expired
78+
assert result.raw_response == {"cancelReason": 0}
79+
assert (
80+
str(result) == "GoogleVerificationResult("
81+
"raw_response={'cancelReason': 0}, "
82+
"is_expired=True, "
83+
"is_canceled=True)"
84+
)
85+
6686
# norm
6787
now = datetime.datetime.utcnow().timestamp()
6888
exp_value = now * 1000 + 10**10

0 commit comments

Comments
 (0)