├── tbc_adapter ├── __init__.py ├── p12_converter.py └── adapter.py ├── setup.cfg ├── requirements.txt ├── setup.py ├── LICENSE ├── .gitignore └── README.md /tbc_adapter/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | autopep8==1.3.3 2 | pycodestyle==2.3.1 3 | pyOpenSSL==17.5.0 4 | requests==2.18.4 5 | six==1.11.0 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | # https://python-packaging.readthedocs.io/en/latest/metadata.html 4 | 5 | setup(name="tbc_adapter", 6 | version="0.1.1", 7 | description="TBC payment gateway adapter", 8 | url="https://github.com/Jambazishvili/tbc-adapter", 9 | author="Giorgi (mecko) Jambazishvili", 10 | author_email="giorgi.jambazishvili@gmail.com", 11 | license="MIT", 12 | packages=["tbc_adapter"], 13 | zip_safe=False, 14 | keywords=["TBC", "payment gateway", "online payment"], 15 | classifiers = [ 16 | "Programming Language :: Python :: 3", 17 | "Intended Audience :: Developers", 18 | "Operating System :: OS Independent", 19 | ], 20 | install_requires=[ 21 | "requests", 22 | "pyopenssl" 23 | ]) 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Giorgi Jambazishvili 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 | -------------------------------------------------------------------------------- /tbc_adapter/p12_converter.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from OpenSSL.crypto import (FILETYPE_PEM, dump_certificate, dump_privatekey, 4 | load_pkcs12) 5 | 6 | 7 | def generate_pems(cert, password, out_dir, **kw): 8 | """ 9 | function generates (converts) .pem certificate and .pem 10 | private key from .p12 certificate and password 11 | 12 | params: 13 | cert [str] - .p12 certificate full path 14 | password [str] - .p12 certificate password 15 | out_dir [str] - output directory full path 16 | 17 | optional keyword params: 18 | cert_name [str] - generated certificate name (incl extension .pem) 19 | key_name [str] - generated certificate name (incl extension .pem) 20 | """ 21 | cert_path = os.path.join(out_dir, kw.pop("cert_name", "certificate.pem")) 22 | key_path = os.path.join(out_dir, kw.pop("key_name", "privatekey.pem")) 23 | 24 | with open(cert, 'rb') as stream: 25 | p12file = stream.read() 26 | 27 | p12file = load_pkcs12(p12file, password) 28 | pem_cert = dump_certificate(FILETYPE_PEM, p12file.get_certificate()) 29 | pem_key = dump_privatekey(FILETYPE_PEM, p12file.get_privatekey()) 30 | 31 | with open(cert_path, 'wb') as stream: 32 | stream.write(pem_cert + pem_key) 33 | 34 | with open(key_path, 'wb') as stream: 35 | stream.write(pem_key) 36 | 37 | return cert_path, key_path 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | project_env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # dotenv 86 | .env 87 | 88 | # virtualenv 89 | .venv 90 | venv/ 91 | ENV/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /tbc_adapter/adapter.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import requests 4 | 5 | 6 | class TBCAdapterException(Exception): 7 | pass 8 | 9 | 10 | class TBCAdapterMeta(type): 11 | """ 12 | meta class to ensure that inherited class implements 13 | not implemented attributes. Also decorate api methods 14 | to automatically return marked outputs from response dict 15 | """ 16 | 17 | def __new__(cls, name, bases, body): 18 | if not 'pem_paths' in body: 19 | raise TBCAdapterException("class must have pem_paths property, " 20 | "returning paths of pem key and cert") 21 | if not 'service_url' in body: 22 | raise TBCAdapterException("class must have pem_paths property, " 23 | "returning payment gateway service url") 24 | 25 | for k, v in body.items(): 26 | if callable(v) and hasattr(v, "api_out"): 27 | body[k] = TBCAdapterMeta.api_method(v) 28 | 29 | return super().__new__(cls, name, bases, body) 30 | 31 | @staticmethod 32 | def api_method(method): 33 | def wrapper(*a, **kw): 34 | method(*a, **kw) 35 | get = a[0].response.get 36 | return a[0].response if not get("status", True) else \ 37 | {k: get(k) for k in getattr(method, "api_out", ())} 38 | return wrapper 39 | 40 | 41 | class TBCAdapter(metaclass=TBCAdapterMeta): 42 | client_ip = None # issuer client ip address 43 | trans_id = None # transaction id (existing / bank generated) 44 | response = None # bank gateway response converted to dict 45 | 46 | def __init__(self, client_ip, trans_id=None): 47 | self.trans_id = trans_id 48 | self.client_ip = client_ip 49 | 50 | # GATEWAY API METHODS 51 | 52 | def get_transaction_id(self, amount, **kw): 53 | self._trans_related_common(amount, 'v', **kw) 54 | get_transaction_id.api_out = ("TRANSACTION_ID", ) 55 | 56 | def get_transaction_status(self): 57 | self._request({ 58 | 'client_ip_addr': self.client_ip, 59 | 'trans_id': self.trans_id, 60 | 'command': 'c' 61 | }) 62 | get_transaction_status.api_out = ("RESULT", "RESULT_CODE", "CARD_NUMBER") 63 | 64 | def end_business_day(self): 65 | self._request({'command': 'b'}) 66 | end_business_day.api_out = ("RESULT", "RESULT_CODE") 67 | 68 | def get_preauthed_transaction_id(self, amount, **kw): 69 | self._trans_related_common(amount, 'a', **kw) 70 | get_preauthed_transaction_id.api_out = ("TRANSACTION_ID", ) 71 | 72 | def commit_preauthed(self, amount, **kw): 73 | self._trans_related_common(amount, 't', **kw) 74 | commit_preauthed.api_out = ("RESULT", "RESULT_CODE", "CARD_NUMBER") 75 | 76 | def reverse_transaction(self): 77 | self._request({'command': 'r', 'trans_id': self.trans_id}) 78 | reverse_transaction.api_out = ("RESULT", "RESULT_CODE") 79 | 80 | def refund_transaction(self, amount=None): 81 | """ 82 | refund transaction api method, whole transaction amount will be 83 | refunded unless amount is provided 84 | optional params: 85 | amount [float] - transaction amount (<= whole trans amount) 86 | """ 87 | self._request({ 88 | 'trans_id': self.trans_id, 89 | 'amount': amount, # find (Ctrl+F) 'note1' 90 | 'command': 'k' 91 | }) 92 | refund_transaction.api_out = ("RESULT", "RESULT_CODE") 93 | 94 | # INTERNAL UTIL METHODS 95 | 96 | def _trans_related_common(self, amount, cmd, **kw): 97 | """ 98 | params: 99 | amount [float] - transaction amount 100 | cmd [str] - command code 101 | optional params: 102 | desc [ascii str] - transaction description 103 | currecy [str] - transaction currency code (ISO 4217) 104 | msg_type [str] - message type 105 | language [str] - language code 106 | """ 107 | self._request({ 108 | 'desc': kw.pop('desc', 'not provided'), 109 | 'currency': kw.pop('currency', '981'), 110 | 'msg_type': kw.pop('msg_type', 'SMS'), 111 | 'language': kw.pop('language', 'GE'), 112 | 'client_ip_addr': self.client_ip, 113 | 'trans_id': self.trans_id, # find (Ctrl+F) 'note1' 114 | 'amount': amount, 115 | 'command': cmd 116 | }) 117 | 118 | def _request(self, payload): 119 | """send POST request to self.service_url & store normalized response""" 120 | try: 121 | payload = {k: v for k, v in payload.items() if v is not None} 122 | response = requests.post(url=self.service_url, data=payload, 123 | cert=self.pem_paths, verify=False, timeout=3) 124 | 125 | if response.status_code == 200: 126 | self.response = self._raw_to_dict(response.text) 127 | self.response.update(status=True) 128 | else: 129 | raise requests.exceptions.HTTPError 130 | except requests.exceptions.ConnectTimeout: 131 | self.response = {"status": False, "desc": "timeout"} 132 | except requests.exceptions.SSLError: 133 | self.response = {"status": False, "desc": "ssl error"} 134 | except requests.exceptions.HTTPError: 135 | self.response = {"status": False, "desc": "http error"} 136 | except Exception: 137 | self.response = {"status": False, "desc": "general exception"} 138 | 139 | def _raw_to_dict(self, raw): 140 | """util method for converting gateway response to dict""" 141 | return dict(x.split(": ") for x in raw.split("\n") if x.strip() != "") 142 | 143 | # PROPS 144 | @property 145 | def pem_paths(self): 146 | """property for returning pem formatted certificate and private key""" 147 | raise NotImplementedError("pem_paths property must be implemented") 148 | 149 | @property 150 | def service_url(self): 151 | """property for returning bank gateway service url""" 152 | raise NotImplementedError("service_url property must be implemented") 153 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Payment Gateway adapter for TBC Bank 2 | 3 | #### შესავალი (მოკლე აღწერა) 4 | მოცემული Python package წარმოადგენს ადაპტერს TBC-ის ონლაინ გადახდებისთვის, სადაც მოცემულია ყველა საჭირო ნაწილი, რომელიც მომხმარებელს საშუალებას მისცემს სერთიფიკატისა და პაროლის არსებობის შემთხვევაში მარტივად მოახდინოს დოკუმენტაციაში აღწერილი API მეთოდების გამოყენება. 5 | 6 | #### დაყენება / ინსტალაცია & python-ის ვერსია 7 | 8 | package-ი დატესტილია და მუშაობს python3-თვის (>3.4). წესით, python2-ზეც არ უნდა ჰქონდეს პრობლემა, მაგრამ დარწმუნებით ვერ გეტყვით :). 9 | 10 | package-ის დაყენების 2 გზა არსებობს: 11 | 12 | 1) ჩამოტვირთეთ პირდაპირ გიტჰაბიდან და გაუშვით `python3 setup.py install` 13 | 2) pip-ის გამოყენებით `pip3 install tbc_adapter` 14 | 15 | happy coding! 16 | 17 | #### API-ს იმპლემენტირებული მეთოდები 18 | TBC API-სთან საურთიერთობოდ იმპლემენტირებულია შემდეგი 7 ძირითადი მეთოდი, რომელიც ქვემოთ არის მოცემული, ხოლო სანამ მეთოდების აღწერაზე გადავალ, საჭიროა შევქმნათ ჩვენი კლასი, რომლის მშობელიც იქნება `tbc_adapter.adapters.TBCAdapter` და იმპლემენტაცია გავუკეთოთ 2 property-ს. ქვემოთ არის მოცემული მაგალითი რეალური მუშა გარემოდან, რომლითაც შეგიძლიათ იხელმძღვანელოთ: 19 | 20 | ```python 21 | import os 22 | from tbc_adapter.adapters import TBCAdapter 23 | from tbc_adapter.p12_converter import generate_pems 24 | 25 | # ცვლადში არის მისამართი სადაც ცხოვრობს TBC-გან მოწოდებული .p12 გაფართოების ფაილი 26 | # და იცხოვრებენ დაგენერირებული .pem გაფართოების ფაილები 27 | CERTIFICATE_DIR = "/abs/path/to/certificate/" 28 | # ცვლადში არის .p12 სერთიფიკატის პაროლი, რომელიც მოწოდებულია TBC ბანკის მიერ 29 | CERTIFICATE_PSWD = "secret" 30 | 31 | class MyFancyAdapter(TBCAdapter): 32 | @property 33 | def pem_paths(self): 34 | p12_path = os.path.join(CERTIFICATE_DIR, "certificate.p12") 35 | cert_path = os.path.join(CERTIFICATE_DIR, "certificate.pem") 36 | key_path = os.path.join(CERTIFICATE_DIR, "privatekey.pem") 37 | if not (os.path.isfile(cert_path) or os.path.isfile(key_path)): 38 | cert_path, key_path = generate_pems(p12_path, 39 | CERTIFICATE_PSWD, 40 | CERTIFICATE_DIR) 41 | return (cert_path, key_path) 42 | 43 | @property 44 | def service_url(self): 45 | return "https://securepay.ufc.ge:18443/ecomm2/MerchantHandler" 46 | ``` 47 | 48 | პირველი property - `pem_paths` აბრუნებს .pem ფორმატში გადაყვანილ სერთიფიკატს და მის გასაღებს, ხოლო თუ ეს ორი კომპონენტი დირექტორიაში არ არის, მაშინ აგენერირებს, ინახავს დირექტორიაში და აბრუნებს მისამართებს. ხოლო, property `service_path` აბრუნებს ბანკის სერვისის მისამართს. 49 | 50 | ასევე, ქვემოთ, საჩვენებელ კოდში, გამოყენებულია ცვლადები: 51 | ```python 52 | client_ip # მომხმარებლის IP მისამართი (იხ.დოკუმენტაცია) 53 | amount # გადასახდელი თანხა თეთრებში (1 ლარი 100 თეთრი) 54 | trans_id # TBC-გან დაბრუნებული ტრანზაქციის ID (იხ.დოკუმენტაცია) 55 | ``` 56 | 57 | ახლა შეგვიძლია გადავიდეთ ჩვენი `MyFancyAdapter` კლასის გამოყენებაზე და სათითაოდ გავიაროთ იმპლემენტირებული მეთოდები: 58 | 59 | #### 1) ტრანზაქციის id-ის დაგენერირება (თანხის ჩამოჭრით) 60 | 61 | იღებს: client_ip, amount 62 | აბრუნებს: TRANSACTION_ID 63 | 64 | ```python 65 | client_ip = "xxx.xxx.xxx.xxx" 66 | amount = 3000 67 | 68 | adapter = MyFanceAdapter(client_ip) 69 | result = adapter.get_transaction_id(amount) 70 | 71 | print(result) # >>> {"TRANSACTION_ID": "xyz"} 72 | ``` 73 | 74 | #### 2) ტრანზაქციის სტატუსის გაგება 75 | 76 | იღებს: client_ip, trans_id 77 | აბრუნებს: RESULT, RESULT_CODE, CARD_NUMBER 78 | 79 | ```python 80 | client_ip = "xxx.xxx.xxx.xxx" 81 | trans_id = "xyz" 82 | 83 | adapter = MyFanceAdapter(client_ip, trans_id) 84 | result = adapter.get_transaction_status() 85 | 86 | print(result) # >>> {"RESULT": "x", "RESULT_CODE": "y", CARD_NUMBER: "z"} 87 | ``` 88 | 89 | ##### 3) დღის დახურვის ოპერაცია 90 | 91 | იღებს: client_ip (სერვერის მისამართი) 92 | აბრუნებს: RESULT, RESULT_CODE 93 | 94 | ```python 95 | client_ip = "xxx.xxx.xxx.xxx" 96 | 97 | adapter = MyFanceAdapter(client_ip) 98 | result = adapter.end_business_day() 99 | 100 | print(result) # >>> {"RESULT": "x", "RESULT_CODE": "y"} 101 | ``` 102 | 103 | ##### 4) პრე-ავტორიზაცია (თანხის დაბლოკვა) 104 | 105 | იღებს: client_ip, amount (რა რაოდენობაც მომხმარებელს დაებლოკება) 106 | აბრუნებს: TRANSACTION_ID 107 | 108 | ```python 109 | client_ip = "xxx.xxx.xxx.xxx" 110 | amount = 3000 111 | 112 | adapter = MyFanceAdapter(client_ip) 113 | result = adapter.get_preauthed_transaction_id(amount) 114 | 115 | print(result) # >>> {"TRANSACTION_ID": "xyz"} 116 | ``` 117 | 118 | ##### 5) პრე-ავტორიზებული ტრანზაქციის კომიტი (თანხის ჩამოჭრა) 119 | 120 | იღებს: client_ip, amount, trans_id 121 | აბრუნებს: RESULT, RESULT_CODE, CARD_NUMBER 122 | 123 | ```python 124 | client_ip = "xxx.xxx.xxx.xxx" 125 | trans_id = "xyz" 126 | amount = 3000 127 | 128 | adapter = MyFanceAdapter(client_ip, trans_id) 129 | result = adapter.commit_preauthed(amount) 130 | 131 | print(result) # >>> {"RESULT": "x", "RESULT_CODE": "y", CARD_NUMBER: "z"} 132 | ``` 133 | 134 | ##### 6) ტრანზაქციის რევერსალი 135 | იღებს: client_ip, trans_id 136 | აბრუნებს: RESULT, RESULT_CODE 137 | 138 | ```python 139 | client_ip = "xxx.xxx.xxx.xxx" 140 | trans_id = "xyz" 141 | 142 | adapter = MyFancyAdapter(client_ip, trans_id) 143 | result = adapter.reverse_transaction() 144 | 145 | print(result) # >>> {"RESULT_CODE": "x", "RESULT": "y"} 146 | ``` 147 | 148 | ##### 7) ტრანზაქციის რეფანდი 149 | იღებს: client_ip, trans_id, amount 150 | აბრუნებს: RESULT, RESULT_CODE 151 | 152 | ```python 153 | client_ip = "xxx.xxx.xxx.xxx" 154 | trans_id = "xyz" 155 | amount = 3000 156 | 157 | adapter = MyFancyAdapter(client_ip, trans_id) 158 | # თუ ნაწილობრივი refund-ს ვანხორციელებთ 159 | result = adapter.refund_transaction(amount) 160 | # თუ სრულ refund-ს ვანხორციელებთ 161 | result = adapter.refund_transaction() 162 | 163 | print(result) # >>> {"RESULT": "x", "RESULT_CODE": "y"} 164 | ``` 165 | 166 | #### API მეთოდებიდან დაბრუნებული ცვლადების მოდიფიკაცია 167 | ადაპტერის ყველა მეთოდი უკან აბრუნებს ლექსიკონს (dict), რომელიც შეიცავს ბანკისგან დაბრუნებულ მნიშვნელობებს. მაგალითად, პრეავტორიზაციის კომიტი უკან აბრუნებს არამარტო RESULT_CODE, RESULT-სა და CARD_NUMBER-ს, არამედ, დამატებით RRN-სა და APPROVAL_CODE-ს, რომელიც შეიძლება საერთოდ არ გამოვიყენოთ, მაგრამ შესაძლებელია რაღაც მომენტში საჭირო გახდეს, ამიტომ თუ გვსურს ზემოთ აღნიშნული 2 მნიშვნელობაც მივიღოთ პირველ სამთან ერთად უბრალოდ საჭიროა შემდეგი, მარტივი, მოდიფიკაცია: 168 | 169 | ```python 170 | # imports 171 | 172 | class MyFancyAdapter(TBCAdapter): 173 | # implementations & definitions 174 | 175 | def commit_preauthed(self, amount): 176 | super().commit_preauthed(amount) 177 | commit_preauthed.api_out = ("RESULT", "RESULT_CODE", "CARD_NUMBER", "RRN", "APPROVAL_CODE") 178 | ``` 179 | 180 | თითოეულ API მეთოდს გააჩნია api_out (list/tuple ტიპის "დესკრიპტორი"), რომელიც ერთგვარი მეტა ინფორმაციაა, რა ცვლადები უნდა დააბრუნოს ადაპტერმა უკან კონკრეტული მეთოდებისთვის. თუ `api_out` სიის მსგავს ობიექტში არსებული გასაღები ბანკიდან დაბრუნებულ პასუხში არ აღმოჩნდა, მაშინ მისი მნიშვნელობა იქნება `None`. 181 | 182 | #### Exception-ები 183 | 184 | რაც შეეხება შეცდომებს: 185 | 186 | 1) ადაპტერი "აწევს" `TBCAdapterException` ტიპის exception-ს, როდესაც შვილობილ კლასში არ იქნება იმპლემენტირებული `pem_paths` & `service_url` property-ები (შვილობილი კლასის ტიპის ობიექტის შექმნისთანავე) 187 | 2) `requests.exceptions.HTTPError`-ს თუ ბანკიდან დაბრუნებული პასუხის `status_code` არ იქნება 200 188 | 189 | ხოლო TBC API მეთოდის გაგზავნის დროს ადაპტერი დაიჭერს (არსებობის შემთხვევაში) შემდეგ შეცდომებს: 190 | 191 | 1) `requests.exceptions.ConnectTimeout` 192 | 2) `requests.exceptions.SSLError` 193 | 3) `requests.exceptions.HTTPError` 194 | 4) და ბოლოს ყველა სხვას - `Exception` 195 | 196 | შეცდომის დაჭერის შემთხვევაში ჩუმი (silent) პრინციპით ხდება მოგვარება და API მეთოდის შედეგში, `api_out`-ში მითითებული წევრების ნაცვლად მოდის ლექსიკონი შემდეგი ხელწერით: 197 | 198 | `{"status": False, "desc": "exc. type"}` 199 | 200 | ...შეიძლება შეცდომის `desc` უფრო verbose იყოს, მაგრამ ახლა არ არის... 201 | 202 | 203 | --------------------------------------------------------------------------------