├── tox.ini ├── pyinapp ├── __init__.py ├── errors.py ├── purchase.py ├── googleplay.py └── appstore.py ├── .travis.yml ├── setup.py ├── .gitignore ├── LICENSE ├── tests ├── test_purchase.py └── test_inapperror.py └── README.rst /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{27,35} 3 | 4 | [testenv] 5 | commands = py.test -v 6 | deps = 7 | pytest 8 | mock -------------------------------------------------------------------------------- /pyinapp/__init__.py: -------------------------------------------------------------------------------- 1 | from .appstore import AppStoreValidator 2 | from .googleplay import GooglePlayValidator 3 | from .errors import InAppValidationError 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.6" 4 | - "2.7" 5 | - "3.3" 6 | - "3.4" 7 | - "3.5" 8 | - "pypy" 9 | install: 10 | - "pip install -e ." 11 | script: py.test -v -------------------------------------------------------------------------------- /pyinapp/errors.py: -------------------------------------------------------------------------------- 1 | 2 | class InAppValidationError(Exception): 3 | """ Base class for all validation errors """ 4 | 5 | def __init__(self, msg, response=None): 6 | super(InAppValidationError, self).__init__(msg) 7 | self.response = response 8 | 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | setup( 5 | name='pyinapp', 6 | version='0.1.4', 7 | packages=['pyinapp'], 8 | install_requires=['rsa', 'requests'], 9 | description="InApp purchase validation API wrappers", 10 | keywords='inapp store purchase googleplay appstore market', 11 | author='Ivan Mukhin', 12 | author_email='muhin.ivan@gmail.com', 13 | url='https://github.com/keeprocking/pyinapp', 14 | long_description=open('README.rst').read(), 15 | license='MIT' 16 | ) 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | *~ 61 | -------------------------------------------------------------------------------- /pyinapp/purchase.py: -------------------------------------------------------------------------------- 1 | 2 | class Purchase(object): 3 | 4 | def __init__(self, transaction_id, product_id, quantity, purchased_at, response=None): 5 | self.transaction_id = transaction_id 6 | self.product_id = product_id 7 | self.quantity = quantity 8 | self.purchased_at = purchased_at 9 | self.response = response 10 | 11 | @classmethod 12 | def from_app_store_receipt(cls, receipt, response): 13 | purchase = { 14 | 'transaction_id': receipt['transaction_id'], 15 | 'product_id': receipt['product_id'], 16 | 'quantity': receipt['quantity'], 17 | 'purchased_at': receipt['purchase_date'], 18 | 'response': response, 19 | } 20 | return cls(**purchase) 21 | 22 | @classmethod 23 | def from_google_play_receipt(cls, receipt): 24 | purchase = { 25 | 'transaction_id': receipt.get('orderId', receipt.get('purchaseToken')), 26 | 'product_id': receipt['productId'], 27 | 'quantity': 1, 28 | 'purchased_at': receipt['purchaseTime'] 29 | } 30 | return cls(**purchase) 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Ivan Mukhin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /pyinapp/googleplay.py: -------------------------------------------------------------------------------- 1 | from pyinapp.errors import InAppValidationError 2 | from pyinapp.purchase import Purchase 3 | import base64 4 | import json 5 | import rsa 6 | 7 | 8 | purchase_state_ok = 0 9 | 10 | 11 | def make_pem(public_key): 12 | return '\n'.join(( 13 | '-----BEGIN PUBLIC KEY-----', 14 | '\n'.join(public_key[i:i+64] for i in range(0, len(public_key), 64)), 15 | '-----END PUBLIC KEY-----' 16 | )) 17 | 18 | 19 | class GooglePlayValidator(object): 20 | 21 | def __init__(self, bundle_id, api_key): 22 | self.bundle_id = bundle_id 23 | pem = make_pem(api_key) 24 | self.public_key = rsa.PublicKey.load_pkcs1_openssl_pem(pem) 25 | 26 | def validate(self, receipt, signature): 27 | ok = self._validate_signature(receipt, signature) 28 | 29 | if not ok: 30 | raise InAppValidationError('Bad signature') 31 | 32 | try: 33 | receipt_json = json.loads(receipt) 34 | 35 | if receipt_json['packageName'] != self.bundle_id: 36 | raise InAppValidationError('Bundle id mismatch') 37 | 38 | if receipt_json['purchaseState'] != purchase_state_ok: 39 | raise InAppValidationError('Item is not purchased') 40 | 41 | return [Purchase.from_google_play_receipt(receipt_json)] 42 | except (KeyError, ValueError): 43 | raise InAppValidationError('Bad receipt') 44 | 45 | def _validate_signature(self, receipt, signature): 46 | try: 47 | sig = base64.standard_b64decode(signature) 48 | return rsa.verify(receipt.encode(), sig, self.public_key) 49 | except (rsa.VerificationError, TypeError): 50 | return False 51 | -------------------------------------------------------------------------------- /tests/test_purchase.py: -------------------------------------------------------------------------------- 1 | from pyinapp.purchase import Purchase 2 | 3 | 4 | def test_create_from_google_play_receipt(): 5 | receipt = { 6 | 'orderId': 1337, 7 | 'productId': 'pew pew', 8 | 'purchaseTime': '01.01.2016 12:00' 9 | } 10 | purchase = Purchase.from_google_play_receipt(receipt) 11 | 12 | assert purchase.transaction_id == receipt['orderId'] 13 | assert purchase.product_id == receipt['productId'] 14 | assert purchase.purchased_at == receipt['purchaseTime'] 15 | assert purchase.quantity == 1 16 | assert purchase.response == None 17 | 18 | 19 | def test_create_from_test_google_play_receipt(): 20 | receipt = { 21 | 'purchaseToken': 1337, 22 | 'productId': 'pew pew', 23 | 'purchaseTime': '01.01.2016 12:00' 24 | } 25 | purchase = Purchase.from_google_play_receipt(receipt) 26 | 27 | assert purchase.transaction_id == receipt['purchaseToken'] 28 | assert purchase.product_id == receipt['productId'] 29 | assert purchase.purchased_at == receipt['purchaseTime'] 30 | assert purchase.quantity == 1 31 | 32 | 33 | def test_create_from_app_store_receipt(): 34 | response = '["in_app":[]]' 35 | receipt = { 36 | 'transaction_id': 1337, 37 | 'product_id': 'pew pew', 38 | 'purchase_date': '01.01.2016 12:00', 39 | 'quantity': 100500, 40 | } 41 | purchase = Purchase.from_app_store_receipt(receipt, response) 42 | 43 | assert purchase.transaction_id == receipt['transaction_id'] 44 | assert purchase.product_id == receipt['product_id'] 45 | assert purchase.purchased_at == receipt['purchase_date'] 46 | assert purchase.quantity == receipt['quantity'] 47 | assert purchase.response == response 48 | -------------------------------------------------------------------------------- /tests/test_inapperror.py: -------------------------------------------------------------------------------- 1 | try: 2 | from unittest import mock 3 | except ImportError: 4 | import mock 5 | 6 | from pyinapp.appstore import AppStoreValidator 7 | 8 | 9 | def test_include_response_on_status_error(): 10 | response = {"status": 1} 11 | bundle_id = 'com.test.ok' 12 | 13 | with mock.patch('requests.post') as mock_post: 14 | mock_json = mock.Mock() 15 | mock_json.json.return_value = response 16 | mock_post.return_value = mock_json 17 | 18 | try: 19 | validator = AppStoreValidator(bundle_id).validate('') 20 | except Exception as e: 21 | assert e.response == response 22 | 23 | 24 | def test_include_response_on_bundle_id_error(): 25 | receipt = {"in_app":[], "bundle_id": "com.test.wrong"} 26 | response = {"status": 0, "in_app":[], "receipt": receipt} 27 | bundle_id = 'com.test.ok' 28 | 29 | with mock.patch('requests.post') as mock_post: 30 | mock_json = mock.Mock() 31 | mock_json.json.return_value = response 32 | mock_post.return_value = mock_json 33 | 34 | try: 35 | validator = AppStoreValidator(bundle_id).validate(receipt) 36 | except Exception as e: 37 | assert e.response == response 38 | 39 | 40 | def test_include_response_on_bid_error(): 41 | receipt = {"bid": "com.test.wrong"} 42 | response = {"status": 0, "in_app":[], "receipt": receipt} 43 | bundle_id = 'com.test.ok' 44 | 45 | with mock.patch('requests.post') as mock_post: 46 | mock_json = mock.Mock() 47 | mock_json.json.return_value = response 48 | mock_post.return_value = mock_json 49 | 50 | try: 51 | validator = AppStoreValidator(bundle_id).validate(receipt) 52 | except Exception as e: 53 | assert e.response == response 54 | -------------------------------------------------------------------------------- /pyinapp/appstore.py: -------------------------------------------------------------------------------- 1 | from pyinapp.purchase import Purchase 2 | from pyinapp.errors import InAppValidationError 3 | from requests.exceptions import RequestException 4 | import requests 5 | 6 | 7 | api_result_ok = 0 8 | api_result_errors = { 9 | 21000: 'Bad json', 10 | 21002: 'Bad data', 11 | 21003: 'Receipt authentication', 12 | 21004: 'Shared secret mismatch', 13 | 21005: 'Server is unavailable', 14 | 21006: 'Subscription has expired', 15 | 21007: 'Sandbox receipt was sent to the production env', 16 | 21008: 'Production receipt was sent to the sandbox env', 17 | } 18 | 19 | 20 | class AppStoreValidator(object): 21 | 22 | def __init__(self, bundle_id, sandbox=False): 23 | self.bundle_id = bundle_id 24 | 25 | if sandbox: 26 | self.url = 'https://sandbox.itunes.apple.com/verifyReceipt' 27 | else: 28 | self.url = 'https://buy.itunes.apple.com/verifyReceipt' 29 | 30 | def validate(self, receipt, password=None): 31 | receipt_json = {'receipt-data': receipt} 32 | if password: 33 | receipt_json['password'] = password 34 | 35 | try: 36 | api_response = requests.post(self.url, json=receipt_json).json() 37 | except (ValueError, RequestException): 38 | raise InAppValidationError('HTTP error') 39 | 40 | status = api_response['status'] 41 | 42 | if status != api_result_ok: 43 | error = InAppValidationError(api_result_errors.get(status, 'Unknown API status'), api_response) 44 | raise error 45 | 46 | receipt = api_response['receipt'] 47 | purchases = self._parse_receipt(receipt, api_response) 48 | return purchases 49 | 50 | def _parse_receipt(self, receipt, response): 51 | if 'in_app' in receipt: 52 | return self._parse_ios7_receipt(receipt, response) 53 | return self._parse_ios6_receipt(receipt, response) 54 | 55 | def _parse_ios6_receipt(self, receipt, response): 56 | if self.bundle_id != receipt['bid']: 57 | error = InAppValidationError('Bundle id mismatch', response) 58 | raise error 59 | return [Purchase.from_app_store_receipt(receipt, response)] 60 | 61 | def _parse_ios7_receipt(self, receipt, response): 62 | if self.bundle_id != receipt['bundle_id']: 63 | error = InAppValidationError('Bundle id mismatch', response) 64 | raise error 65 | return [Purchase.from_app_store_receipt(r, response) for r in receipt['in_app']] 66 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pyinapp 2 | ======= 3 | |travis| |pypi| 4 | 5 | .. |travis| image:: https://travis-ci.org/keeprocking/pyinapp.svg?branch=master 6 | :target: https://travis-ci.org/keeprocking/pyinapp 7 | .. |pypi| image:: https://badge.fury.io/py/pyinapp.svg 8 | :target: https://badge.fury.io/py/pyinapp 9 | 10 | In-app purchase validation has never been so friendly and convenient! 11 | 12 | Installation 13 | ============ 14 | :: 15 | 16 | pip install pyinapp 17 | 18 | Usage 19 | ===== 20 | 21 | Currently pyinapp supports Google Play and App Store receipts validation. 22 | 23 | Google Play: 24 | ------------ 25 | .. code:: python 26 | 27 | from pyinapp import GooglePlayValidator, InAppValidationError 28 | 29 | 30 | bundle_id = 'com.yourcompany.yourapp' 31 | api_key = 'API key from the developer console' 32 | validator = GooglePlayValidator(bundle_id, api_key) 33 | 34 | try: 35 | purchases = validator.validate('receipt', 'signature') 36 | process_purchases(purchases) 37 | except InAppValidationError: 38 | """ handle validation error """ 39 | 40 | App Store: 41 | ---------- 42 | .. code:: python 43 | 44 | from pyinapp import AppStoreValidator, InAppValidationError 45 | 46 | 47 | bundle_id = 'com.yourcompany.yourapp' 48 | validator = AppStoreValidator(bundle_id) 49 | 50 | try: 51 | purchases = validator.validate('receipt') 52 | process_purchases(purchases) 53 | except InAppValidationError: 54 | """ handle validation error """ 55 | 56 | **Important!** 57 | If your version is under 0.1.3, you need to check the type of purchases. For the sake of convenience you can process purchases this way: 58 | 59 | .. code:: python 60 | 61 | def process_purchases(purchases): 62 | process(*purchases) if isinstance(purchases, list) else process(purchases) 63 | 64 | 65 | def process(*purchases): 66 | for p in purchases: 67 | """ for instance, save p to db and add a player some coins for it """ 68 | 69 | 70 | This approach allows to process both Google Play and App Store purchases the same way. 71 | 72 | Purchase 73 | ======== 74 | 75 | Purchase is a universal wrapper for Google Play and App Store receipts. It contains the following fields: 76 | 77 | - **transaction_id**: id of the purchase (**transaction_id** for App Store and **orderId** for Google Play); 78 | - **product_id**: what product has been purchased (**product_id** for App Store and **productId** for Google Play); 79 | - **quantity**: how many products have been purchased (**quantity** for App Store and always **1** for Google Play - there's no such field in Google Play receipt); 80 | - **purchased_at**: when the product has been purchased, UNIX timestamp (**purchase_date** for App Store and **purchaseTime** for Google Play). 81 | - **response**: (App Store only) the response (in JSON format) from the App Store. 82 | 83 | Contributing 84 | ============ 85 | 86 | To run tests, you'll need tox_. After installing, simply run it: 87 | 88 | .. code:: python 89 | 90 | tox 91 | 92 | .. _tox: https://pypi.python.org/pypi/tox --------------------------------------------------------------------------------