Skip to content

Commit

Permalink
Atol: support protocol v4 (#13)
Browse files Browse the repository at this point in the history
* Atol: support protocol v4

* bump version

* tox: py27

* travis conf: add python 2.7

* run py27 only with django 1.10

* wip

* pinning celery version

* pinning celery version

* revert support py2.7

* migrate some vars to settings

* fix tests

* correct readme
  • Loading branch information
cephey authored Dec 18, 2018
1 parent 69b4a2d commit 47ce5a8
Show file tree
Hide file tree
Showing 8 changed files with 89 additions and 57 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
Changelog
---------

1.3.0 (2018-11-28)
------------------
* Support Atol protocol v4 (FFD 1.05)

1.2.2 (2018-10-08)
------------------
* Change maximum retry counts for task `atol_receive_receipt_report`. Now its awaiting report for 29 hours.
Expand Down
6 changes: 5 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,13 @@ Quick start
RECEIPTS_ATOL_PASSWORD = 'secret'
RECEIPTS_ATOL_GROUP_CODE = 'ATOL-ProdTest-1'
RECEIPTS_ATOL_TAX_NAME = 'vat18'
RECEIPTS_ATOL_TAX_SYSTEM = 'osn'
RECEIPTS_ATOL_INN = '112233445573'
RECEIPTS_ATOL_PAYMENT_METHOD = 'full_payment'
RECEIPTS_ATOL_PAYMENT_OBJECT = 'service'
RECEIPTS_ATOL_CALLBACK_URL = None
RECEIPTS_ATOL_PAYMENT_ADDRESS = 'г. Москва, ул. Оранжевая, д.22 к.11'
RECEIPTS_ATOL_COMPANY_EMAIL = '<your_company>@gmail.com'
RECEIPTS_OFD_URL_TEMPLATE = u'https://lk.platformaofd.ru/web/noauth/cheque?fn={fn}&fp={fp}'

3. Add celery-beat tasks to CELERYBEAT_SCHEDULE settings like this::
Expand Down Expand Up @@ -107,4 +111,4 @@ Quick start
Run tests
---------

python setup.py test
pytest
2 changes: 1 addition & 1 deletion atol/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '1.2.2'
__version__ = '1.3.0'
80 changes: 47 additions & 33 deletions atol/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from django.conf import settings
from django.core.cache import cache
from django.utils.translation import ugettext_lazy as _
from model_utils import Choices

from atol import exceptions

Expand All @@ -17,8 +19,17 @@
class AtolAPI(object):
request_timeout = 5

ErrorCode = Choices(
(1, 'PROCESSING_FAILED', _('Ошибка обработки входящего документа')),
(32, 'VALIDATION_ERROR', _('Ошибка валидации входного чека')),
(33, 'ALREADY_EXISTS', _('Документ с переданными значениями <external_id> '
'и <group_code> уже существует в базе')),
(34, 'STATE_CHECK_NOT_FOUND', _('Документ еще не обработан')),
(40, 'BAD_REQUEST', _('Некорректный запрос')),
)

def __init__(self):
self.base_url = getattr(settings, 'RECEIPTS_ATOL_BASE_URL', None) or 'https://online.atol.ru/possystem/v3'
self.base_url = getattr(settings, 'RECEIPTS_ATOL_BASE_URL', None) or 'https://online.atol.ru/possystem/v4'

def _obtain_new_token(self):
"""
Expand All @@ -29,8 +40,10 @@ def _obtain_new_token(self):
'login': settings.RECEIPTS_ATOL_LOGIN,
'pass': settings.RECEIPTS_ATOL_PASSWORD,
})
# all codes other than 0 (new token) and 1 (existing token) are considered errors
if response_data.get('code') not in (0, 1):

error = response_data.get('error')
if error:
logger.error('fail obtained auth token due to %s', error['text'], extra={'data': error})
raise exceptions.AtolAuthTokenException()

auth_token = response_data['token']
Expand Down Expand Up @@ -73,27 +86,27 @@ def request(self, method, endpoint, json=None):
"""
auth_token = self._get_auth_token()

params = {
'tokenid': auth_token
headers = {
'Token': auth_token
}

# signed requests contain group codes in front of the endpoint name
endpoint = '{group_code}/{endpoint}'.format(group_code=settings.RECEIPTS_ATOL_GROUP_CODE,
endpoint=endpoint)

try:
return self._request(method, endpoint, params=params, json=json)
return self._request(method, endpoint, headers=headers, json=json)
except exceptions.AtolAuthTokenException:
# token must have expired, try new one
logger.info('trying new token for request "%s" to endpoint %s with params=%s json=%s token=%s',
method, endpoint, params, json, auth_token)
params.update({'tokenid': self._get_auth_token(force_renew=True)})
return self._request(method, endpoint, params=params, json=json)
logger.info('trying new token for request "%s" to endpoint %s with headers=%s json=%s',
method, endpoint, headers, json)
headers.update({'Token': self._get_auth_token(force_renew=True)})
return self._request(method, endpoint, headers=headers, json=json)

def _request(self, method, endpoint, params=None, headers=None, json=None):
params = params or {}
headers = headers or {}
headers.setdefault('Content-Type', 'application/json')
headers.setdefault('Content-Type', 'application/json; charset=utf-8')

url = '{base_url}/{endpoint}'.format(base_url=self.base_url.rstrip('/'),
endpoint=endpoint)
Expand All @@ -112,15 +125,9 @@ def _request(self, method, endpoint, params=None, headers=None, json=None):

# error codes other than 2xx, 400, 401 are considered unexpected and yield an exception
if response.status_code not in (200, 201, 400, 401):
try:
json_response = response.json()
except Exception:
json_response = None
logger.warning('request %s %s with headers=%s, params=%s json=%s failed with status code %s: %s',
method, url, headers, params, json, response.status_code, json_response,
extra={'data': {'json_request': json,
'content': response.content,
'json_response': json_response}})
method, url, headers, params, json, response.status_code, response.content,
extra={'data': {'json_request': json, 'content': response.content}})
raise exceptions.AtolRequestException()

# 401 should be handled separately by the calling code
Expand All @@ -136,13 +143,13 @@ def _request(self, method, endpoint, params=None, headers=None, json=None):
extra={'data': {'content': response.content}})
raise exceptions.AtolRequestException()

if response_data.get('error'):
logger.warning('received error response from atol url %s: %s',
url, response_data['error'],
extra={'data': {'json': json, 'params': params}})
error = response_data.get('error')
if error:
logger.warning('received error response from atol url %s due to %s', url, error.get('text', ''),
extra={'data': {'json': json, 'params': params, 'error': error}})
raise exceptions.AtolClientRequestException(response=response,
response_data=response_data,
error_data=response_data['error'])
error_data=error)

return response_data

Expand Down Expand Up @@ -178,17 +185,26 @@ def sell(self, **params):
'external_id': params['transaction_uuid'],
'timestamp': timestamp.strftime('%d.%m.%Y %H:%M:%S'),
'receipt': {
# user supplied details
'attributes': {
'client': {
'email': user_email or u'',
'phone': user_phone or u'',
},
'company': {
'email': settings.RECEIPTS_ATOL_COMPANY_EMAIL,
'sno': settings.RECEIPTS_ATOL_TAX_SYSTEM,
'inn': settings.RECEIPTS_ATOL_INN,
'payment_address': settings.RECEIPTS_ATOL_PAYMENT_ADDRESS,
},
'items': [{
'name': params['purchase_name'],
'price': purchase_price,
'quantity': 1,
'sum': purchase_price,
'tax': settings.RECEIPTS_ATOL_TAX_NAME,
'payment_method ': settings.RECEIPTS_ATOL_PAYMENT_METHOD,
'payment_object': settings.RECEIPTS_ATOL_PAYMENT_OBJECT,
'vat': {
'type': settings.RECEIPTS_ATOL_TAX_NAME,
},
}],
'payments': [{
'sum': purchase_price,
Expand All @@ -197,9 +213,7 @@ def sell(self, **params):
'total': purchase_price,
},
'service': {
'inn': settings.RECEIPTS_ATOL_INN,
'callback_url': settings.RECEIPTS_ATOL_CALLBACK_URL or u'',
'payment_address': settings.RECEIPTS_ATOL_PAYMENT_ADDRESS,
}
}

Expand All @@ -208,9 +222,9 @@ def sell(self, **params):
# check for recoverable errors
except exceptions.AtolClientRequestException as exc:
logger.info('sell request with json %s failed with code %s', request_data, exc.error_data['code'])
if exc.error_data['code'] in (1, 4, 5, 6):
if exc.error_data['code'] in (self.ErrorCode.VALIDATION_ERROR, self.ErrorCode.BAD_REQUEST):
raise exceptions.AtolRecoverableError()
if exc.error_data['code'] == 10:
if exc.error_data['code'] == self.ErrorCode.ALREADY_EXISTS:
logger.info('sell request with json %s already accepted; uuid: %s',
request_data, exc.response_data['uuid'])
return NewReceipt(uuid=exc.response_data['uuid'], data=exc.response_data)
Expand All @@ -234,9 +248,9 @@ def report(self, receipt_uuid):
# check for recoverable errors
except exceptions.AtolClientRequestException as exc:
logger.info('report request for receipt %s failed with code %s', receipt_uuid, exc.error_data['code'])
if exc.error_data['code'] in (7, 9, 12, 13, 14, 16):
if exc.error_data['code'] in (self.ErrorCode.STATE_CHECK_NOT_FOUND, self.ErrorCode.BAD_REQUEST):
raise exceptions.AtolRecoverableError()
if exc.error_data['code'] == 1:
if exc.error_data['code'] == self.ErrorCode.PROCESSING_FAILED:
logger.info('report request for receipt %s was not processed: %s; '
'Must repeat the request with a new unique value <external_id>',
receipt_uuid, exc.response_data.get('text'))
Expand Down
2 changes: 1 addition & 1 deletion tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
ATOL_BASE_URL = 'https://online.atol.ru/possystem/v3'
ATOL_BASE_URL = 'https://online.atol.ru/possystem/v4'
46 changes: 26 additions & 20 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ def test_atol_create_receipt_workflow():
'timestamp': '13.07.2017 18:32:49',
'status': 'wait',
'error': {
'code': 16,
'error_id': 'e710f5de-0b20-47de-8ae8-d193016c5a4e',
'code': 34,
'text': 'Нет информации, попробуйте позднее',
'type': 'system'
},
Expand Down Expand Up @@ -83,7 +84,8 @@ def test_atol_create_receipt_workflow():
'group_code': 'ATOL-ProdTest-1',
'daemon_code': 'prod-agent-1',
'device_code': 'KSR13.11-3-1',
'callback_url': ''
'external_id': 'TRF20801_1',
'callback_url': '',
}
url = ATOL_BASE_URL + '/ATOL-ProdTest-1/report/' + uid
responses.add(responses.GET, url, status=200, json=data)
Expand All @@ -94,10 +96,11 @@ def test_atol_create_receipt_workflow():
'timestamp': '13.07.2017 18:32:50',
'status': 'fail',
'error': {
'code': 10,
'error_id': '01b46c9d-f829-4ecf-b07c-7b096d0b985e',
'code': 33,
'text': 'В системе существует чек с external_id : ec0ce0c6-7a31-4f45-b94f-a1442be3bb9c '
'и group_code: ATOL-ProdTest-1',
'type': 'system'
'type': 'system',
}
}
url = ATOL_BASE_URL + '/ATOL-ProdTest-1/sell'
Expand Down Expand Up @@ -133,10 +136,12 @@ def test_atol_create_receipt_workflow():


@pytest.mark.parametrize('status,params', [
(200, {'body': ConnectionError()}),
(200, {'body': 'Wrong JSON'}),
(500, {'body': b''}),
(302, {'body': b''}),
(400, {'json': {'error': {'code': 1}}}),
(400, {'json': {'error': {'code': 6}}}),
(400, {'json': {'error': {'code': 32}}}),
(400, {'json': {'error': {'code': 40}}}),
])
@responses.activate
def test_atol_sell_recoverable_errors(status, params, set_atol_token):
Expand All @@ -154,8 +159,8 @@ def test_atol_sell_recoverable_errors(status, params, set_atol_token):


@pytest.mark.parametrize('status,params', [
(400, {'json': {'error': {'code': 3}}}),
(400, {'json': {'error': {'code': 22}}}),
(400, {'json': {'error': {'code': 31}}}),
(400, {'json': {'error': {'code': 51}}}),
])
@responses.activate
def test_atol_sell_unrecoverable_errors(status, params, set_atol_token):
Expand Down Expand Up @@ -193,7 +198,7 @@ def test_atol_sell_expired_token_is_renewed(set_atol_token, caches):

receipt = atol.sell(**sell_params)
assert receipt.uuid == receipt_uuid
assert resp_mock.calls[0].request.url.endswith('?tokenid=12345')
assert resp_mock.calls[0].request.headers['Token'] == '12345'

# token is updated
assert caches['default'].get(ATOL_AUTH_CACHE_KEY) == 'foobar'
Expand All @@ -209,7 +214,7 @@ def test_atol_sell_expired_token_is_renewed(set_atol_token, caches):

receipt = atol.sell(**sell_params)
assert receipt.uuid == anoher_receipt_uuid
assert resp_mock.calls[0].request.url.endswith('?tokenid=foobar')
assert resp_mock.calls[0].request.headers['Token'] == 'foobar'


def test_atol_sell_expired_token_is_failed_to_renew(set_atol_token):
Expand All @@ -218,7 +223,7 @@ def test_atol_sell_expired_token_is_failed_to_renew(set_atol_token):

with responses.RequestsMock(assert_all_requests_are_fired=True) as resp_mock:
resp_mock.add(responses.POST, ATOL_BASE_URL + '/ATOL-ProdTest-1/sell', status=401)
resp_mock.add(responses.POST, ATOL_BASE_URL + '/getToken', status=400, json={'code': 19})
resp_mock.add(responses.POST, ATOL_BASE_URL + '/getToken', status=400, json={'code': 12})

sell_params = dict(timestamp=datetime.now(), transaction_uuid=str(uuid4()),
purchase_name=u'Стандартная подписка на 1 месяц', purchase_price='199.99',
Expand All @@ -229,28 +234,29 @@ def test_atol_sell_expired_token_is_failed_to_renew(set_atol_token):


@pytest.mark.parametrize('status,params', [
(200, {'body': ConnectionError()}),
(200, {'body': 'Wrong JSON'}),
(500, {'body': b''}),
(302, {'body': b''}),
(400, {'json': {'error': {'code': 7}}}),
(400, {'json': {'error': {'code': 12}}}),
(400, {'json': {'error': {'code': 16}}}),
(400, {'json': {'error': {'code': 34}}}),
(400, {'json': {'error': {'code': 40}}}),
])
@responses.activate
def test_atol_report_recoverable_errors(status, params, set_atol_token):
atol = AtolAPI()
set_atol_token('12345')
payment_uuid = str(uuid4())

responses.add(responses.POST, ATOL_BASE_URL + '/ATOL-ProdTest-1/report/%s' % payment_uuid,
responses.add(responses.GET, ATOL_BASE_URL + '/ATOL-ProdTest-1/report/%s' % payment_uuid,
status=status, **params)

with pytest.raises(AtolRecoverableError):
atol.report(payment_uuid)


@pytest.mark.parametrize('status,params', [
(400, {'json': {'error': {'code': 3}}}),
(400, {'json': {'error': {'code': 15}}}),
(400, {'json': {'error': {'code': 30}}}),
(400, {'json': {'error': {'code': 31}}}),
])
@responses.activate
def test_atol_report_unrecoverable_errors(status, params, set_atol_token):
Expand All @@ -269,13 +275,13 @@ def test_atol_api_base_url():
"""
We Check base_url in case that RECEIPTS_ATOL_BASE_URL is not specified in settings
"""
assert AtolAPI().base_url == 'https://online.atol.ru/possystem/v3'
assert AtolAPI().base_url == 'https://online.atol.ru/possystem/v4'


@pytest.mark.parametrize('settings_url, api_base_url', [
('test_url', 'test_url'),
('', 'https://online.atol.ru/possystem/v3'),
(None, 'https://online.atol.ru/possystem/v3')
('', 'https://online.atol.ru/possystem/v4'),
(None, 'https://online.atol.ru/possystem/v4')
])
def test_atol_api_base_url_customizing(settings_url, api_base_url):
"""
Expand Down
4 changes: 4 additions & 0 deletions tests/test_app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,11 @@
RECEIPTS_ATOL_PASSWORD = 'secret'
RECEIPTS_ATOL_GROUP_CODE = 'ATOL-ProdTest-1'
RECEIPTS_ATOL_TAX_NAME = 'vat18'
RECEIPTS_ATOL_TAX_SYSTEM = 'osn'
RECEIPTS_ATOL_INN = '112233445573'
RECEIPTS_ATOL_PAYMENT_METHOD = 'full_payment'
RECEIPTS_ATOL_PAYMENT_OBJECT = 'service'
RECEIPTS_ATOL_CALLBACK_URL = None
RECEIPTS_ATOL_PAYMENT_ADDRESS = 'г. Москва, ул. Оранжевая, д.22 к.11'
RECEIPTS_ATOL_COMPANY_EMAIL = ''
RECEIPTS_OFD_URL_TEMPLATE = u'https://lk.platformaofd.ru/web/noauth/cheque?fn={fn}&fp={fp}'
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[tox]
envlist = py{35,py36}-django{111,20}, flake8
envlist = py{35,36}-django{111,20}, flake8

[testenv]
deps =
Expand Down

0 comments on commit 47ce5a8

Please sign in to comment.