diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..203b584 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.pythonPath": "/Users/achachiez/.pyenv/versions/pretix/bin/python" +} \ No newline at end of file diff --git a/pretix_mpesa/migrations/0001_initial.py b/pretix_mpesa/migrations/0001_initial.py new file mode 100644 index 0000000..e1ecb5e --- /dev/null +++ b/pretix_mpesa/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 2.1.1 on 2018-09-19 18:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='OnlineCheckout', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('phone', models.BigIntegerField()), + ('amount', models.DecimalField(decimal_places=2, max_digits=20)), + ('checkout_request_id', models.CharField(default='', max_length=50)), + ('account_reference', models.CharField(default='', max_length=50)), + ('transaction_description', models.CharField(blank=True, max_length=50, null=True)), + ('customer_message', models.CharField(blank=True, max_length=100, null=True)), + ('merchant_request_id', models.CharField(blank=True, max_length=50, null=True)), + ('response_code', models.CharField(blank=True, max_length=5, null=True)), + ('response_description', models.CharField(blank=True, max_length=100, null=True)), + ('date_added', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name_plural': 'Online Checkout Requests', + 'db_table': 'tbl_online_checkout_requests', + }, + ), + ] diff --git a/pretix_mpesa/migrations/0002_auto_20180919_1853.py b/pretix_mpesa/migrations/0002_auto_20180919_1853.py new file mode 100644 index 0000000..d3db69a --- /dev/null +++ b/pretix_mpesa/migrations/0002_auto_20180919_1853.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1.1 on 2018-09-19 18:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretix_mpesa', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='onlinecheckout', + name='mpesa_receipt_number', + field=models.CharField(default='', max_length=50), + ), + migrations.AddField( + model_name='onlinecheckout', + name='received', + field=models.BooleanField(default=False), + ), + ] diff --git a/pretix_mpesa/migrations/0003_auto_20180919_1911.py b/pretix_mpesa/migrations/0003_auto_20180919_1911.py new file mode 100644 index 0000000..dc1c185 --- /dev/null +++ b/pretix_mpesa/migrations/0003_auto_20180919_1911.py @@ -0,0 +1,40 @@ +# Generated by Django 2.1.1 on 2018-09-19 19:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretix_mpesa', '0002_auto_20180919_1853'), + ] + + operations = [ + migrations.CreateModel( + name='OnlineCheckoutResponse', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('merchant_request_id', models.CharField(blank=True, max_length=50, null=True)), + ('checkout_request_id', models.CharField(default='', max_length=50)), + ('result_code', models.CharField(blank=True, max_length=5, null=True)), + ('result_description', models.CharField(blank=True, max_length=100, null=True)), + ('mpesa_receipt_number', models.CharField(blank=True, max_length=50, null=True)), + ('transaction_date', models.DateTimeField(blank=True, null=True)), + ('phone', models.BigIntegerField(blank=True, null=True)), + ('amount', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True)), + ('date_added', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name_plural': 'Online Checkout Responses', + 'db_table': 'tbl_online_checkout_responses', + }, + ), + migrations.RemoveField( + model_name='onlinecheckout', + name='mpesa_receipt_number', + ), + migrations.RemoveField( + model_name='onlinecheckout', + name='received', + ), + ] diff --git a/pretix_mpesa/migrations/__init__.py b/pretix_mpesa/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pretix_mpesa/models.py b/pretix_mpesa/models.py new file mode 100644 index 0000000..9ca3681 --- /dev/null +++ b/pretix_mpesa/models.py @@ -0,0 +1,45 @@ + +from django.db import models +class OnlineCheckout(models.Model): + """ + Handles Online Checkout + """ + id = models.BigAutoField(primary_key=True) + phone = models.BigIntegerField() + amount = models.DecimalField(max_digits=20, decimal_places=2) + checkout_request_id = models.CharField(max_length=50, default='') + account_reference = models.CharField(max_length=50, default='') + transaction_description = models.CharField(max_length=50, blank=True, null=True) + customer_message = models.CharField(max_length=100, blank=True, null=True) + merchant_request_id = models.CharField(max_length=50, blank=True, null=True) + response_code = models.CharField(max_length=5, blank=True, null=True) + response_description = models.CharField(max_length=100, blank=True, null=True) + date_added = models.DateTimeField(auto_now_add=True) + def __str__(self): + return str(self.phone) + + class Meta: + db_table = 'tbl_online_checkout_requests' + verbose_name_plural = 'Online Checkout Requests' + +class OnlineCheckoutResponse(models.Model): + """ + Handles Online Checkout Response + """ + id = models.BigAutoField(primary_key=True) + merchant_request_id = models.CharField(max_length=50, blank=True, null=True) + checkout_request_id = models.CharField(max_length=50, default='') + result_code = models.CharField(max_length=5, blank=True, null=True) + result_description = models.CharField(max_length=100, blank=True, null=True) + mpesa_receipt_number = models.CharField(max_length=50, blank=True, null=True) + transaction_date = models.DateTimeField(blank=True, null=True) + phone = models.BigIntegerField(blank=True, null=True) + amount = models.DecimalField(max_digits=20, decimal_places=2, blank=True, null=True) + date_added = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return str(self.phone) + + class Meta: + db_table = 'tbl_online_checkout_responses' + verbose_name_plural = 'Online Checkout Responses' \ No newline at end of file diff --git a/pretix_mpesa/payment.py b/pretix_mpesa/payment.py index bf14c62..fcc09e2 100644 --- a/pretix_mpesa/payment.py +++ b/pretix_mpesa/payment.py @@ -1,8 +1,8 @@ import json import logging import urllib.parse -from collections import OrderedDict import phonenumbers +import math from pympesa import Pympesa from django import forms @@ -11,9 +11,11 @@ from django.template.loader import get_template from django.utils.translation import ugettext as __, ugettext_lazy as _ from django.utils.functional import cached_property +from collections import OrderedDict +from django.http import HttpRequest from pretix.base.decimal import round_decimal -from pretix.base.models import Order, Quota, RequiredAction +from pretix.base.models import Order, Quota, RequiredAction,OrderPayment, OrderRefund from pretix.base.payment import BasePaymentProvider, PaymentException from pretix.base.services.mail import SendMailException from pretix.base.services.orders import mark_order_paid, mark_order_refunded @@ -33,7 +35,9 @@ class Mpesa(BasePaymentProvider): verbose_name = _('Mpesa') payment_form_fields = OrderedDict([ ]) - + @property + def abort_pending_allowed(self): + return False @cached_property def cart_session(self): return cart_session(self.request) @@ -77,6 +81,12 @@ def settings_form_fields(self): required=True, help_text=_('The password for encrypting the request') )), + ('stk_callback_url', + forms.CharField( + label=_('Mpesa STK Callback'), + required=True, + help_text=_('This is the callback url for mpesa stk') + )), ('mpesa_phone_number_field_required', forms.BooleanField( label=_('Will the mpesa phone number be required to place an order'), @@ -117,6 +127,7 @@ def checkout_prepare(self, request, cart): return False else: if phonenumbers.is_valid_number(parsed_num): + request.session['mpesa_phone_number'] = '254' + str(parsed_num.national_number) return True else: messages.error(request, _('The Mpesa number is not a valid phone number')) @@ -128,7 +139,7 @@ def payment_is_valid_session(self, request): def order_can_retry(self, order): return self._is_still_available(order=order) - def payment_perform(self, request, order) -> str: + def execute_payment(self, request: HttpRequest, payment: OrderPayment): """ Will be called if the user submitted his order successfully to initiate the payment process. @@ -144,14 +155,19 @@ def payment_perform(self, request, order) -> str: kwargs = {} if request.resolver_match and 'cart_namespace' in request.resolver_match.kwargs: kwargs['cart_namespace'] = request.resolver_match.kwargs['cart_namespace'] + parsed_num = request.session.get('mpesa_phone_number', '') + logger.debug(parsed_num) mode = self.settings.get('endpoint') consumer_key = self.settings.get('safaricom_consumer_key') consumer_secret = self.settings.get('safaricom_consumer_secret') business_short_code = self.settings.get('mpesa_shortcode') password = self.settings.get('encryption_password') - callback_url = ''.join(build_absolute_uri(request.event, 'plugins:pretix_mpesa:callback', kwargs=kwargs)), + amount = math.ceil(payment.amount) + callback_url = self.settings.get('stk_callback_url') + logger.debug(amount) + logger.debug(callback_url) send_stk.apply_async(kwargs={'consumer_key': consumer_key, 'consumer_secret': consumer_secret, 'business_short_code': business_short_code, - 'password': password, 'amount': 10, 'phone': '254700247286', 'order_number': 'Test Order', + 'password': password, 'amount': str(amount), 'phone': parsed_num, 'order_number': str(payment.id), 'callback_url': callback_url, 'mode': mode}) return None diff --git a/pretix_mpesa/tasks.py b/pretix_mpesa/tasks.py index 66eb480..970cc71 100644 --- a/pretix_mpesa/tasks.py +++ b/pretix_mpesa/tasks.py @@ -1,7 +1,12 @@ -from pretix.base.services.async import ProfiledTask +from pretix.base.services.tasks import ProfiledTask +from .models import OnlineCheckout +import base64 import pympesa +import uuid from pretix.celery_app import app +from decimal import Decimal +import uuid import logging logger = logging.getLogger('pretix.plugins.mpesa') import time @@ -12,20 +17,32 @@ def send_stk(consumer_key, consumer_secret, business_short_code, password, amoun access_token = response.get("access_token") from pympesa import Pympesa mpesa_client = Pympesa(access_token,mode) + time_stamp = pympesa.generate_timestamp() + encoded_password = encode_password(shortcode=business_short_code,passkey=password,timestamp=time_stamp) response = mpesa_client.lipa_na_mpesa_online_payment( BusinessShortCode=business_short_code, - Password=password, - Timestamp=time.strftime('%Y%m%d%H%M%S'), + Password=encoded_password , + Timestamp=time_stamp, TransactionType="CustomerPayBillOnline", - Amount="100", - PartyA="254708374149", + Amount=amount, + PartyA=phone, PartyB=business_short_code, PhoneNumber=phone, - CallBackURL='https://nyachoke.localtunnel.me/bigevents/2019/mpesa/callback', + CallBackURL=callback_url, AccountReference=order_number, TransactionDesc=order_number ) - logger.debug(response.json()) + json_response = response.json() + checkout_id = json_response.get('CheckoutRequestID') + result_code = json_response.get('ResponseCode') + logger.debug(json_response) + if result_code == '0': + OnlineCheckout.objects.create(phone=int(phone), + amount=Decimal(str(amount)), + account_reference=order_number, + checkout_request_id=checkout_id, + transaction_description=order_number) + logger.debug('Created request') @app.task(base=ProfiledTask) def simulate_C2B(consumer_key,consumer_secret): @@ -33,4 +50,11 @@ def simulate_C2B(consumer_key,consumer_secret): access_token = response.get("access_token") from pympesa import Pympesa mpesa_client = Pympesa(access_token) - mpesa_client.c2b_simulate_transaction() \ No newline at end of file + mpesa_client.c2b_simulate_transaction() + +def encode_password(shortcode, passkey, timestamp): + """Generate and return a base64 encoded password for online access. + """ + data = shortcode + passkey + timestamp + data_bytes = data.encode('utf-8') + return base64.b64encode(data_bytes).decode() \ No newline at end of file diff --git a/pretix_mpesa/urls.py b/pretix_mpesa/urls.py index fabfbc0..091e4d0 100644 --- a/pretix_mpesa/urls.py +++ b/pretix_mpesa/urls.py @@ -2,11 +2,11 @@ from pretix.multidomain import event_url -from .views import confirm , validate +from .views import confirm , validate , stk_callback event_patterns = [ url(r'^mpesa/', include([ url(r'^confirm/$', confirm, name='confirm'), url(r'^validate/$', validate, name='validate'), - url(r'^callback/$', validate, name='callback') + url(r'^callback/$', stk_callback, name='callback') ])), ] \ No newline at end of file diff --git a/pretix_mpesa/views.py b/pretix_mpesa/views.py index fd39680..e6cb8aa 100644 --- a/pretix_mpesa/views.py +++ b/pretix_mpesa/views.py @@ -1,10 +1,75 @@ +from django.views.decorators.csrf import csrf_exempt +from django.http import HttpResponse, JsonResponse +import logging +import json +from .models import OnlineCheckoutResponse, OnlineCheckout +from pretix.base.models import Order, Quota, RequiredAction,OrderPayment, OrderRefund +from decimal import Decimal + +logger = logging.getLogger('pretix.plugins.mpesa') + + +@csrf_exempt def confirm(request, *args, **kwargs): message = '' +@csrf_exempt def validate(request, *args, **kwargs): message = '' -def callback(request, *args, **kwargs): - message = '' +@csrf_exempt +def stk_callback(request, *args, **kwargs): + json_data = json.loads(request.body) + logger.info(json_data) + + try: + data = json_data.get('Body', {}).get('stkCallback', {}) + update_data = dict() + update_data['result_code'] = data.get('ResultCode', '') + update_data['result_description'] = data.get('ResultDesc', '') + update_data['checkout_request_id'] = data.get('CheckoutRequestID', '') + update_data['merchant_request_id'] = data.get('MerchantRequestID', '') + + meta_data = data.get('CallbackMetadata', {}).get('Item', {}) + if len(meta_data) > 0: + # handle the meta data + for item in meta_data: + if len(item.values()) > 1: + key, value = item.values() + if key == 'MpesaReceiptNumber': + update_data['mpesa_receipt_number'] = value + if key == 'Amount': + update_data['amount'] = Decimal(value) + if key == 'PhoneNumber': + update_data['phone'] = int(value) + if key == 'TransactionDate': + date = str(value) + year, month, day, hour, min, sec = date[:4], date[4:-8], date[6:-6], date[8:-4], date[10:-2], date[12:] + update_data['transaction_date'] = '{}-{}-{} {}:{}:{}'.format(year, month, day, hour, min, sec) + + # save + checkout_request_id = data.get('CheckoutRequestID', '') + logger.info(dict(updated_data=update_data)) + try: + online_checkout = OnlineCheckout.objects.get(checkout_request_id=checkout_request_id) + logger.info('Checkout') + logger.info(online_checkout.account_reference) + try: + payment = OrderPayment.objects.get(id=online_checkout.account_reference) + logger.info('Payment Found') + try: + payment.confirm() + except Quota.QuotaExceededException: + pass + except OrderPayment.DoesNotExist: + logger.info('Payment Not found') + pass + except OnlineCheckout.DoesNotExist: + logger.info('Not found') + pass + except Exception as ex: + logger.error(ex) + raise ValueError(str(ex)) + return HttpResponse(request)