@@ -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
0 commit comments