Example code on Developer Product purchase handling misses a key point that can cause devs to lose revenue

The example code specified on https://developer.roblox.com/articles/Developer-Products-In-Game-Purchases does not cover a very important point that can cause developers to lose revenue, which is the usage of PurchasedID to check if the user already got the rewards for their purchase.

This information is covered on the page for the specific callback (See https://developer.roblox.com/api-reference/callback/MarketplaceService/ProcessReceipt), however the example script mentioned in the purchases tutorial mentions absolutely nothing about this. This may cause some confusion among developers unfamiliar with the system when their users receive rewards multiple times for a product they only purchased once.

The tutorial page should be updated to reflect this property and how/why it’s used.

8 Likes

I wasn’t aware this was a thing! Out of curiosity, under what circumstances does ProcessReceipt get called after a purchase is already granted?

I’m still not exactly sure, to be entirely honest. It seems to occur if you do multiple purchases in close succession to one-another but even then it doesn’t always happen, so a solid repro isn’t always easy. It’s clear Roblox knew this could happen hence them going out of their way to expose the purchase ID.

I was testing @Nyapaw’s indev game and was able to receive rewards for a purchased currency twice in a row when I had purchased two different options directly after one-another.

2 Likes

I think Roblox should fix this on their end if possible. I would bet good money there’s lots of places with this bug.

This probably isn’t a bug.

I’m not sure if this happens at all. The given example…

…could happen before purchases are granted:

  1. Player purchases Item 1
  2. ProcessReceipt called for one ungranted item, Item 1
  3. Server starts processing Item 1, but yields for DataStore or other reasons
  4. Player purchases Item 2
  5. ProcessReceipt called for two ungranted items, Item 1 and Item 2
  6. Server starts processing Item 1 and 2, but yields for DataStore or other reasons
  7. Step #2 finishes (Item 1 granted)
  8. Step #6 finishes (Item 1 and 2 granted)

This is supposed to happen. If the first attempt has not been granted, it may be due to an API failure or an error that only happens in the given server or player state. In all of those cases, a retry should be made at the next reasonable time. Roblox determines “reasonable time” to be:

  • When another purchase is made
  • Or when you join a new server
  • Or possibly other times that I’m not aware of.

You should be detecting that you’re already actively processing the given receipt/purchase id and not processing it again.


A very good way to handle ProcessReceipt is as follows (click to expand)

In player data:

  • Have a list of receipt ids with the following possible states:
    • Working
    • Probably saved
    • Saved

On player join:

  • Move all “Probably saved” receipts to “Saved”.
  • Clear out any receipts older than 4 days. ProcessReceipt is never called 3 days after purchase. (hint: use os.time())
  • Clear out all “Working” receipts

On ProcessReceipt:

  1. If receipt is set to “Working”, return and don’t do anything – it’s already being processed
  2. If receipt is set to “Probably saved”, return and don’t do anything – it’s already being processed
  3. If receipt is set to “Saved”, return PurchaseGranted
  4. If receipt is not in the player data, then…
    1. Set receipt id state to “Working”
    2. In a protected call (pcall), attempt to give the player what they bought
    3. If it fails for any reason, remove the receipt from their player data and return NotYetProcessed
    4. If it succeeds, set receipt id state to “Probably saved”, then…
    5. Save the player data
    6. If player data save fails, wait for an autosave or for the player to leave. You should autosave every few minutes. Alternatively, keep retrying save until the player leaves.
    7. If player data save succeeds, then set receipt id as “Saved” and return PurchaseGranted
    8. If player leaves or save has to be cancelled for other reasons, return NotYetProcessed

The only hole in this approach is if somehow your ProcessReceipt call ends without data being saved, but it is saved later, and then the player does not play for 3 days, then they will be refunded and have what they bought. If you’re properly waiting for save success (item 4.6) then this should never happen. A player getting something extra is better than them losing a purchase due to a data store outage. Free items are fine. Lost purchases are unacceptable.

This handles all of the following scenarios:

  • ProcessReceipt called while already processing the given receipt (do not process twice)
  • Player leaves half-way through processing (retry later or refund in 3 days)
  • Data cannot be saved, so player loses what they bought if they leave the server (retry later or refund in 3 days)
  • Data gets saved at a later time, after the ProcessReceipt call ends (return PurchaseGranted at next opportunity)
  • Player stays in the game, and data save succeeds (return PurchaseGranted)

This is clearly super long and not the easiest to implement, but that’s not a technical issue with ProcessReceipt. Roblox can make some improvements to make it easier though:

  • Don’t call ProcessReceipt for any receipt ids that still have a ProcessReceipt call actively running
  • Making it possible to tell MarketplaceService that a receipt is done processing from outside of the ProcessReceipt call (e.g. MarketplaceService:SetPurchaseGranted(receiptId))
  • Improve the docs to actually cover all of these scenarios. The last time I looked at the example ProcessReceipt code, it did not handle the majority if any of these scenarios.
7 Likes

That makes more sense, now that I think about it! I must have missed that case when reading the wiki :sweat_smile:

1 Like

But won’t all this saving spam the output with this? Even with just 2 devproduct purchases in a row; a very common occurance

Also it’s been 9 months, has the documentation been improved since you last saw it? Wayback doesn’t have it archived. I think it’s still missing a few scenarios ): MarketplaceService.ProcessReceipt

Player purchases do not always occur often enough for that to be a problem. With a simple saving system and no other Data Store usage, you should be getting around 10 saves per player per minute. In practice this could be lower, but it should not be low enough to cause a problem.

Additionally, you do not have to save as soon as the purchase is made. You just should never return PurchaseGranted until the player data has saved. When implemented properly, this system can handle yielding as long as you need. If you want, you could have each purchase yield until the player data auto-saves successfully instead of starting a save right away. This would keep your Data Store requests at normal levels.

1 Like

If I autosave every 3 minutes, and yield the ProcessReceipt until then, it does this every time you buy another product within that 3 minutes ): image

And if I instead get rid of the yielding, and just automatically return NotProcessedYet, I can’t just force all the current receipts to return PurchaseGranted because ProcessReceipt will only fire/be-able-to-be-returned during a purchase or Join. :weary:

I really dislike how unintuitive this all is, especially how ProcessReceipt keeps firing (and across new servers too)

I don’t think that red error is anything bad. That’s Roblox saying “hey we aren’t running your ProcessReceipt for purchase 27bbaf... twice because that can cause bugs”. You’re still able to buy new products even though that shows, right?

After checking the article, it seems that the example on MarketplaceService.ProcessReceipt is as-intended, but the one in the article does forget to check for duplicate receipt processing. I’ll see to it that the example is updated to use a data store as the one on the ProcessReceipt page. In the meantime, please use that one as a guide instead.

1 Like

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.