play-billing-samples icon indicating copy to clipboard operation
play-billing-samples copied to clipboard

Issues with subscription downgrade with DERREFED Proration/ReplacementMode

Open rh101 opened this issue 2 years ago • 3 comments

Scenario:

Let's say there are several subscription products in the app with the following product IDs:

sub.tier1
sub.tier2
sub.tier3
  1. User purchases monthly tier 1 subscription.
  2. Before that month expires, the user upgrades to a tier 3 subscription with ReplacementMode.CHARGE_PRORATED_PRICE (PBL6).
  3. Still within that same month, the user decides to downgrade back to a tier 1 subscription, which uses ReplacementMode.DEFERRED (PBL6). This means the newly purchased subscription will not be active until the end of the current tier 3 billing cycle.

The first issue is that on step 3, with the user downgrading to the tier 1 subscription inside the app, the onPurchasesUpdated method received the purchase list, but this purchase list does not contain the tier 1 product ID (sub.tier1), but rather the sub.tier3 product ID. While I can understand why it may be returning sub.tier3, since it is still the active subscription, within the context of the purchase, it doesn't seem correct, since the actual purchase is of a product with ID sub.tier1.

The next issue is with the server-side validation and acknowledgement of purchases. When attempting to use the purchaseToken supplied by step 3 above, the response from https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2/get is the following:

{
    "AcknowledgementState" : "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED",
        "CanceledStateContext" : {
            "DeveloperInitiatedCancellation" : null,
            "ReplacementCancellation" : null,
            "SystemInitiatedCancellation" : null,
            "UserInitiatedCancellation" : {
                "CancelSurveyResult" : null,
                "CancelTime" : null,
                "ETag" : null
            },
            "ETag" : null
        },
        "ExternalAccountIdentifiers" : {
            "ExternalAccountId" : null,
            "ObfuscatedExternalAccountId" : null,
            "ObfuscatedExternalProfileId" : null,
            "ETag" : null
        },
        "Kind" : "androidpublisher#subscriptionPurchaseV2",
        "LatestOrderId" : "GPA.XXX-XXX-XXX-XXXXX",
        "LineItems" : [
            {
                "AutoRenewingPlan" : {
                    "AutoRenewEnabled" : null,
                    "PriceChangeDetails" : null,
                    "ETag" : null
                },
                "ExpiryTime" : null,
                "OfferDetails" : {
                    "BasePlanId" : "tier1-monthly",
                    "OfferId" : "tier1-offer-1",
                    "OfferTags" : [
                    ],
                    "ETag" : null
                },
                "PrepaidPlan" : null,
                "ProductId" : "sub.tier1",
                "ETag" : null
            },
            {
                "AutoRenewingPlan" : {
                    "AutoRenewEnabled" : null,
                    "PriceChangeDetails" : null,
                    "ETag" : null
                },
                "ExpiryTime" : "2023-05-26T06:25:31.414+0000",
                "OfferDetails" : {
                    "BasePlanId" : null,
                    "OfferId" : null,
                    "OfferTags" : [
                    ],
                    "ETag" : null
                },
                "PrepaidPlan" : null,
                "ProductId" : "sub.tier3",
                "ETag" : null
            }
        ],
        "LinkedPurchaseToken" : "{REMOVED.PREVIOUS.SUB.TOKEN]",
        "PausedStateContext" : null,
        "RegionCode" : "AU",
        "StartTime" : "2023-05-26T06:23:44.321+0000",
        "SubscribeWithGoogleInfo" : null,
        "SubscriptionState" : "SUBSCRIPTION_STATE_ACTIVE",
        "TestPurchase" : {
            "ETag" : null
        },
        "ETag" : null
}

Notice that "lineItems" contains 2 entries, one for the tier 3 subscription, and another for the tier 1. I haven't come across any documentation indicating that this type of response would occur. Is this documented somewhere?

When the tier 3 subscription finally expires, and the tier 1 becomes active, the server receives a notification from real-time developer notifications (RTDN) with a notification type of SUBSCRIPTION_RENEWED. Using the latest purchase token, a call is made to retrieve the latest purchase info from the Google server, and it results in the following response:

{
    "AcknowledgementState": "ACKNOWLEDGEMENT_STATE_PENDING",
    "CanceledStateContext": null,
    "ExternalAccountIdentifiers": {
        "ExternalAccountId": null,
        "ObfuscatedExternalAccountId": null,
        "ObfuscatedExternalProfileId": null,
        "ETag": null
    },
    "Kind": "androidpublisher#subscriptionPurchaseV2",
    "LatestOrderId": "GPA.XYZ..0",
    "LineItems": [{
            "AutoRenewingPlan": {
                "AutoRenewEnabled": true,
                "PriceChangeDetails": null,
                "ETag": null
            },
            "ExpiryTime": "2023-05-26T06:30:37.382Z",
            "OfferDetails": {
                "BasePlanId": "tier1-monthly",
                "OfferId": "",
                "OfferTags": [],
                "ETag": null
            },
            "PrepaidPlan": null,
            "ProductId": "sub.tier1",
            "ETag": null
        }, {
            "AutoRenewingPlan": {
                "AutoRenewEnabled": null,
                "PriceChangeDetails": null,
                "ETag": null
            },
            "ExpiryTime": "2023-05-26T06:30:37.382Z",
            "OfferDetails": {
                "BasePlanId": null,
                "OfferId": null,
                "OfferTags": [],
                "ETag": null
            },
            "PrepaidPlan": null,
            "ProductId": "sub.tier3",
            "ETag": null
        }
    ],
    "LinkedPurchaseToken": "REMOVED",
    "PausedStateContext": null,
    "RegionCode": "AU",
    "StartTime": "2023-05-26T06:23:44.321Z",
    "SubscribeWithGoogleInfo": null,
    "SubscriptionState": "SUBSCRIPTION_STATE_ACTIVE",
    "TestPurchase": {
        "ETag": null
    },
    "ETag": null
}

Note that now both sub.tier1 and sub.tier3 have expiry dates, which are the same. Also note the ACKNOWLEDGEMENT_STATE_PENDING. A call to acknowledge this renewed subscription with a product ID of sub.tier1 and the same purchase token results in a valid response, so as far as I'm aware, it's acknowledged. But, is it really, because soon after a notification is received from RTDN, type SUBSCRIPTION_REVOKED. Querying the purchase from Google returns this response:

{
    "AcknowledgementState": "ACKNOWLEDGEMENT_STATE_PENDING",
    "CanceledStateContext": {
        "DeveloperInitiatedCancellation": null,
        "ReplacementCancellation": null,
        "SystemInitiatedCancellation": {
            "ETag": null
        },
        "UserInitiatedCancellation": null,
        "ETag": null
    },
    "ExternalAccountIdentifiers": {
        "ExternalAccountId": null,
        "ObfuscatedExternalAccountId": null,
        "ObfuscatedExternalProfileId": null,
        "ETag": null
    },
    "Kind": "androidpublisher#subscriptionPurchaseV2",
    "LatestOrderId": "GPA.REMOVED..1",
    "LineItems": [{
            "AutoRenewingPlan": {
                "AutoRenewEnabled": null,
                "PriceChangeDetails": null,
                "ETag": null
            },
            "ExpiryTime": null,
            "OfferDetails": {
                "BasePlanId": "tier1-monthly",
                "OfferId": "tier1-offer-1",
                "OfferTags": [],
                "ETag": null
            },
            "PrepaidPlan": null,
            "ProductId": "sub.tier1",
            "ETag": null
        }, {
            "AutoRenewingPlan": {
                "AutoRenewEnabled": null,
                "PriceChangeDetails": null,
                "ETag": null
            },
            "ExpiryTime": "2023-05-26T06:30:41.113Z",
            "OfferDetails": {
                "BasePlanId": null,
                "OfferId": null,
                "OfferTags": [],
                "ETag": null
            },
            "PrepaidPlan": null,
            "ProductId": "sub.tier3",
            "ETag": null
        }
    ],
    "LinkedPurchaseToken": "REMOVED",
    "PausedStateContext": null,
    "RegionCode": "AU",
    "StartTime": "2023-05-26T06:23:44.321Z",
    "SubscribeWithGoogleInfo": null,
    "SubscriptionState": "SUBSCRIPTION_STATE_EXPIRED",
    "TestPurchase": {
        "ETag": null
    },
    "ETag": null
}

Note the "AcknowledgementState": "ACKNOWLEDGEMENT_STATE_PENDING" , the "SubscriptionState": "SUBSCRIPTION_STATE_EXPIRED", and the expiry date is now missing from the sub.tier1 line item.

After this another notification is received from RTDN being with type SUBSCRIPTION_EXPIRED, and the resulting purchase response:

{
    "AcknowledgementState": "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED",
    "CanceledStateContext": {
        "DeveloperInitiatedCancellation": null,
        "ReplacementCancellation": null,
        "SystemInitiatedCancellation": {
            "ETag": null
        },
        "UserInitiatedCancellation": null,
        "ETag": null
    },
    "ExternalAccountIdentifiers": {
        "ExternalAccountId": null,
        "ObfuscatedExternalAccountId": null",
        "ObfuscatedExternalProfileId": null,
        "ETag": null
    },
    "Kind": "androidpublisher#subscriptionPurchaseV2",
    "LatestOrderId": "GPA.REMOVED..1",
    "LineItems": [{
            "AutoRenewingPlan": {
                "AutoRenewEnabled": null,
                "PriceChangeDetails": null,
                "ETag": null
            },
            "ExpiryTime": null,
            "OfferDetails": {
                "BasePlanId": "tier1-monthly",
                "OfferId": "tier1-offer-1",
                "OfferTags": [],
                "ETag": null
            },
            "PrepaidPlan": null,
            "ProductId": "sub.tier1",
            "ETag": null
        }, {
            "AutoRenewingPlan": {
                "AutoRenewEnabled": null,
                "PriceChangeDetails": null,
                "ETag": null
            },
            "ExpiryTime": "2023-05-26T06:30:41.113Z",
            "OfferDetails": {
                "BasePlanId": null,
                "OfferId": null,
                "OfferTags": [],
                "ETag": null
            },
            "PrepaidPlan": null,
            "ProductId": "sub.tier3",
            "ETag": null
        }
    ],
    "LinkedPurchaseToken": "REMOVED",
    "PausedStateContext": null,
    "RegionCode": "AU",
    "StartTime": "2023-05-26T06:23:44.321Z",
    "SubscribeWithGoogleInfo": null,
    "SubscriptionState": "SUBSCRIPTION_STATE_EXPIRED",
    "TestPurchase": {
        "ETag": null
    },
    "ETag": null
}

Note the "AcknowledgementState": "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED". How is it now acknowledged when just prior to this it returned ACKNOWLEDGEMENT_STATE_PENDING? As far as I am aware, once the SUBSCRIPTION_REVOKED notification was received, no acknowledge request was ever made to acknowledge that purchaseToken. The only acknowledgement request made was when SUBSCRIPTION_RENEWED was received using RTDN.

What is going on with this? Firstly, is there documentation detailing the responses for upgrades/downgrades with the DEFERRED ReplacementMode/ProrationMode, and secondly, why would the acknowledgement request for this result in a valid OK response from the Google API if it was never actually acknowledged?

The acknowledgement request was sent with a product ID of sub.tier1, since that is what the SUBSCRIPTION_RENEWED notification was about. Is there something I missed in all of this?

rh101 avatar May 26 '23 06:05 rh101

We are also updating to this v2 API endpoint and noticing the lack of proper documentation about how lineItems works.

I am guessing that this is an attempt to improve the situation with downgrades and deferred replacement mode where it is often necessary to do your own bookkeeping when a user changes their subscription in this way, so that now we would have a better way to get the details of the subscription that will replace the current one but hasn't yet. But there's no explanation of this at all in the documentation. It isn't a good idea to make developers guess about how lineItems works, because we didn't create this and our guesses about it could easily be wrong.

Please provide proper documentation for lineItems:

  • In what situations can we assume there will be exactly 1 line item?
  • Are there situations in which there could be 0 line items?
  • When there is more than one item in lineItems, what are the possible items that can be there? How are they ordered? What is the correct way to know which item is which and what caused each item to appear there?

pnico avatar Jun 06 '23 22:06 pnico

We are facing the same problem. No documentation about how lineItems work, and when doing a change of plan mid-subscription active, we are getting the same pair of items in lineItems as explained on OP. It not makes sense with the classic lifecycle nor with the new.

JOGUI22 avatar Jul 18 '23 11:07 JOGUI22

Came here facing the same problem. There is some more documentation on this page: https://developer.android.com/google/play/billing/subscriptions#handle-deferred-replacement

However, I found this to be only almost accurate. Our app uses replacementMode DEFERRED, and our backend receives the RTDNs as described, except no line item does contains deferredItemReplacement, neither do the responses pasted by @rh101.

mklinik avatar Apr 03 '24 11:04 mklinik