diff --git a/apps/core/models.py b/apps/core/models.py index 52dab70..aee706a 100644 --- a/apps/core/models.py +++ b/apps/core/models.py @@ -17,6 +17,22 @@ class BaseModel(models.Model): +class BasePGModel(models.Model): + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(auto_now=True, db_index=True) + + def make_request(self): + pass + + def confirm_request(self): + pass + + def reverse_request(self): + pass + + class Meta: + abstract = True + diff --git a/apps/pg/admin.py b/apps/pg/admin.py index ec9a153..b2d6bf0 100644 --- a/apps/pg/admin.py +++ b/apps/pg/admin.py @@ -1,8 +1,8 @@ from django.contrib import admin -from .models import ChargeRequest +from .models import PaymentRequest -class ChargeRequestAdmin(admin.ModelAdmin): - list_display = ['uuid', 'id'] +class PaymentRequestAdmin(admin.ModelAdmin): + list_display = ['uuid', 'amount', 'user_id'] -admin.site.register(ChargeRequest, ChargeRequestAdmin) \ No newline at end of file +admin.site.register(PaymentRequest, PaymentRequestAdmin) \ No newline at end of file diff --git a/apps/pg/constans.py b/apps/pg/constans.py index 3531ce6..fc0a2db 100644 --- a/apps/pg/constans.py +++ b/apps/pg/constans.py @@ -4,12 +4,15 @@ from django.utils.translation import gettext_lazy as _ class StateChoices(IntegerChoices): CREATED = 1 , _('created') - DELAYED = 2 , _('delayed') # delayed as user wish - PENDING = 3 , _('pending') # wait for external service response (or action) - INCOMPLETE = 4 , _('incomplete') # started and wait for internal progress to complete - SUCCESS = 5 , _('success') - FAILED = 6 , _('failed') - EXPECTED_FAILURE = 7 , _('expected_failure') # no exact data available but guessed to be failed + IPG_PENDING = 2 , _('ipg pending') + IPG_SUCCESS = 3 , _('ipg success') + + WALLET_SUBMIT_PENDING = 11 , _('wallet submit pending') + WALLET_SUBMIT_SUCCESS = 12 , _('wallet submit success') + + FAILED = 18 , _('failed') + EXPECTED_FAILURE = 19 , _('expected failure') # no exact data available but guessed to be failed + SUCCESS = 20 , _('success') class TypeChoices(IntegerChoices): diff --git a/apps/pg/forms.py b/apps/pg/forms.py index 6fc7192..ca27cd3 100644 --- a/apps/pg/forms.py +++ b/apps/pg/forms.py @@ -1,10 +1,10 @@ from django import forms -from .models import ChargeRequest +from .models import PaymentRequest -class ChargeRequestForm(forms.ModelForm): +class PaymentRequestForm(forms.ModelForm): class Meta: - model = ChargeRequest + model = PaymentRequest fields = [] diff --git a/apps/pg/migrations/0003_paymentrequest_pna_delete_chargerequest.py b/apps/pg/migrations/0003_paymentrequest_pna_delete_chargerequest.py new file mode 100644 index 0000000..219e64b --- /dev/null +++ b/apps/pg/migrations/0003_paymentrequest_pna_delete_chargerequest.py @@ -0,0 +1,51 @@ +# Generated by Django 5.1.7 on 2025-05-26 15:28 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pg', '0002_alter_chargerequest_callback_url'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PaymentRequest', + fields=[ + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('gateway', models.CharField(choices=[('fake', 'fake'), ('pna', 'pna')], max_length=255)), + ('state', models.IntegerField(choices=[(1, 'created'), (2, 'ipg pending'), (3, 'ipg success'), (11, 'wallet submit pending'), (12, 'wallet submit success'), (18, 'failed'), (19, 'expected failure'), (20, 'success')], default=1)), + ('amount', models.PositiveBigIntegerField(blank=True, null=True)), + ('callback_url', models.CharField(blank=True, max_length=255, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='PNA', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('details', models.JSONField(blank=True, default=dict, null=True)), + ('pna_token', models.CharField(blank=True, db_index=True, max_length=255, null=True)), + ('pna_order_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('pna_originator', models.CharField(blank=True, max_length=255, null=True)), + ('pna_status', models.IntegerField(blank=True, choices=[(1, 'created'), (2, 'ipg pending'), (3, 'ipg success'), (11, 'wallet submit pending'), (12, 'wallet submit success'), (18, 'failed'), (19, 'expected failure'), (20, 'success')], db_index=True, null=True)), + ('pna_status_code', models.IntegerField(blank=True, null=True)), + ('pna_status_message', models.CharField(blank=True, max_length=255, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True)), + ('payment_request', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='pg.paymentrequest')), + ], + ), + migrations.DeleteModel( + name='ChargeRequest', + ), + ] diff --git a/apps/pg/migrations/0004_paymentrequest_phone_number_and_more.py b/apps/pg/migrations/0004_paymentrequest_phone_number_and_more.py new file mode 100644 index 0000000..2db494e --- /dev/null +++ b/apps/pg/migrations/0004_paymentrequest_phone_number_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.7 on 2025-05-26 16:52 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pg', '0003_paymentrequest_pna_delete_chargerequest'), + ] + + operations = [ + migrations.AddField( + model_name='paymentrequest', + name='phone_number', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='pna', + name='payment_request', + field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='pna', to='pg.paymentrequest'), + ), + ] diff --git a/apps/pg/models.py b/apps/pg/models.py index 86499d5..661fddc 100644 --- a/apps/pg/models.py +++ b/apps/pg/models.py @@ -1,45 +1,68 @@ import urllib.parse +from mimetypes import guess_all_extensions from django.conf import settings from django.db import models +from django.db.models import TextChoices from django.urls import reverse from oauth2_provider.settings import oauth2_settings -from apps.core.models import BaseModel +from apps.core.models import BaseModel, BasePGModel from apps.pg.constans import StateChoices import uuid from utils.wallet_client import deposit_to_user_wallet_submit, withdraw_from_user_wallet_verify, \ deposit_to_user_wallet_verify -from utils.pna import PNAClient +from utils.pna import PNAClient +from django.utils.translation import gettext_lazy as _ -class ChargeRequest(models.Model): - uuid = models.UUIDField(editable=False, default=uuid.uuid4, unique=True, db_index=True) +class GatewayChoise(TextChoices): + FAKE = "fake", _("fake") + PNA = 'pna' , _('pna') + + +class PaymentRequest(models.Model): + id = None + uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True) application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.PROTECT, related_name='+', null=True, blank=True) + + gateway = models.CharField(max_length=255, choices=GatewayChoise.choices) + state = models.IntegerField(choices=StateChoices.choices, default=StateChoices.CREATED) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) amount = models.PositiveBigIntegerField(null=True, blank=True) + phone_number = models.CharField(max_length=255, null=True, blank=True) callback_url = models.CharField(max_length=255, null=True, blank=True) - details = models.JSONField(null=True, blank=True, default=dict) - - pna_token = models.CharField(null=True, blank=True, max_length=255, db_index=True) - pna_order_id = models.IntegerField(null=True, blank=True, db_index=True) - pna_originator = models.CharField(null=True, blank=True, max_length=255) - pna_status = models.IntegerField(choices=StateChoices.choices, null=True, blank=True, db_index=True) - pna_status_code = models.IntegerField(null=True, blank=True) - pna_status_message = models.CharField(null=True, blank=True, max_length=255) created_at = models.DateTimeField(auto_now_add=True, db_index=True) updated_at = models.DateTimeField(auto_now=True, db_index=True) + def get_gateway_tools(self): + if self.gateway == 'pna': + return self.pna + + + def set_gateway(self, gateway): + self.gateway = gateway + if gateway == 'pna': + pna = PNA.objects.create() + pna.payment_request = self + + if gateway == 'fake': + pass + + self.save() + + + # TODO: complete and use this def change_state(self, from_state, to_state, error_message=None, **data): if self.state == from_state: - updated = ChargeRequest.objects.filter(pk=self.pk, state=from_state).update( + updated = PaymentRequest.objects.filter(pk=self.pk, state=from_state).update( state=to_state, **data ) @@ -50,44 +73,13 @@ class ChargeRequest(models.Model): else: raise Exception(error_message) - def request_sale(self): - pna_client = PNAClient() - - description = 'from addwin ipg' - pna_callback_url = urllib.parse.urljoin( - settings.IPG_BASE_PUBLIC_URL, - reverse("pg:charge-confirm-view", kwargs={"uuid": self.uuid})) - response = pna_client.request_normal_sale(self.amount, description, pna_callback_url, order_id=self.id) - - self.pna_status_code = response['status'] - self.pna_status_message = response.get('title') - - if response['status'] == 0: - self.pna_token = response['token'] - - self.save() - - def get_pna_redirect_url(self): - if self.pna_token: - return PNAClient.pg_base_url.format(token=self.pna_token) - - - def confirm_sale(self): - pna_client = PNAClient() - description = 'from addwin ipg' - response = pna_client.request_normal_sale_confirm(self.pna_token) - # response = {'status': 0, 'cardNumberMasked': '504172******9210', 'rrn': 511629210215, 'appToken': 4101434578524, 'token': 'NDEwMTQzNDU3ODUyNA=='} - - if response['status'] == 0: - self.pna_status = StateChoices.SUCCESS.value - def charge_wallet_balance(self): - if self.state in [StateChoices.CREATED]: - to_pending = ChargeRequest.objects.filter( + if self.state in [StateChoices.IPG_SUCCESS]: + to_pending = PaymentRequest.objects.filter( pk=self.pk, - state=StateChoices.CREATED.value, + state=StateChoices.IPG_SUCCESS.value, ).update( - state=StateChoices.PENDING.value) + state=StateChoices.WALLET_SUBMIT_PENDING.value) if not to_pending: raise Exception('Cannot charge balance because state') @@ -107,14 +99,29 @@ class ChargeRequest(models.Model): if not submit_response.uuid: raise Exception('Failed to submit payment in wallet') + to_success = PaymentRequest.objects.filter( + pk=self.pk, + state=StateChoices.WALLET_SUBMIT_PENDING.value, + ).update( + state=StateChoices.WALLET_SUBMIT_SUCCESS.value) + + if not to_success: + raise Exception('Cannot charge balance because state') + verify_response = deposit_to_user_wallet_verify(self.uuid) if not verify_response.uuid or verify_response.state != 5: + to_pay_failed = PaymentRequest.objects.filter( + pk=self.pk, + state=StateChoices.WALLET_SUBMIT_SUCCESS.value + ).update( + state=StateChoices.FAILED.value, + ) raise Exception('Failed to verify payment in wallet') - to_pay_success = ChargeRequest.objects.filter( + to_pay_success = PaymentRequest.objects.filter( pk=self.pk, - state=StateChoices.PENDING.value + state=StateChoices.WALLET_SUBMIT_SUCCESS.value ).update( state=StateChoices.SUCCESS.value, ) @@ -123,3 +130,56 @@ class ChargeRequest(models.Model): raise Exception('Failed to update state payment') return bool(to_pay_success) + + + +class PNA(BasePGModel): + payment_request = models.OneToOneField(PaymentRequest, on_delete=models.PROTECT, related_name='pna') + + details = models.JSONField(null=True, blank=True, default=dict) + + pna_token = models.CharField(null=True, blank=True, max_length=255, db_index=True) + pna_order_id = models.IntegerField(null=True, blank=True, db_index=True) + pna_originator = models.CharField(null=True, blank=True, max_length=255) + pna_status = models.IntegerField(choices=StateChoices.choices, null=True, blank=True, db_index=True) + pna_status_code = models.IntegerField(null=True, blank=True) + pna_status_message = models.CharField(null=True, blank=True, max_length=255) + + + client = PNAClient() + + def make_request(self): + amount = self.payment_request.amount + description = 'from addwin ipg' + callback_url = urllib.parse.urljoin( + settings.IPG_BASE_PUBLIC_URL, + reverse("pg:payment-confirm-view", kwargs={"uuid": self.payment_request.uuid})) + order_id = self.id + phone_number = self.payment_request.phone_number + + response = self.client.request_normal_sale(amount, description, callback_url, order_id, phone_number) + self.pna_status_code = response['status'] + self.pna_status_message = response.get('title') + + if response['status'] == 0: + self.pna_token = response['token'] + + self.save() + + def get_redirect_url(self): + if self.pna_token: + return self.client.pg_base_url.format(token=self.pna_token) + + def confirm_request(self): + pna_token = self.pna_token + response = self.client.request_normal_sale_confirm(pna_token) + # response = {'status': 0, 'cardNumberMasked': '504172******9210', 'rrn': 511629210215, 'appToken': 4101434578524, 'token': 'NDEwMTQzNDU3ODUyNA=='} + self.details = response + if response['status'] == 0: + self.pna_status = StateChoices.SUCCESS.value + self.save() + + def reverse_request(self): + raise NotImplementedError() + + diff --git a/apps/pg/serializers.py b/apps/pg/serializers.py index 5f059c6..baffb9c 100644 --- a/apps/pg/serializers.py +++ b/apps/pg/serializers.py @@ -1,13 +1,13 @@ from rest_framework import serializers -from .models import ChargeRequest +from .models import PaymentRequest -class ChargeRequestSerializer(serializers.ModelSerializer): +class PaymentRequestSerializer(serializers.ModelSerializer): # uuid = serializers.UUIDField(required=True) class Meta: - model = ChargeRequest + model = PaymentRequest fields = ( 'uuid', 'user', diff --git a/apps/pg/urls.py b/apps/pg/urls.py index 525e72f..292f7d9 100644 --- a/apps/pg/urls.py +++ b/apps/pg/urls.py @@ -1,10 +1,10 @@ from django.urls import path, include -from .views import ChargeRequestView, ChargeRequestCreateApiView, ChargeConfirmView +from .views import PaymentRequestView, PaymentRequestCreateApiView, PaymentConfirmView app_name = "pg" urlpatterns = [ - path('page/charge//', ChargeRequestView.as_view(), name='charge-request-view'), - path('page/pna_callback//', ChargeConfirmView.as_view(), name='charge-confirm-view'), - path('api/charge_create/', ChargeRequestCreateApiView.as_view(), name='charge-request-create-api-view'), + path('page/charge//', PaymentRequestView.as_view(), name='payment-request-view'), + path('page/pna_callback//', PaymentConfirmView.as_view(), name='payment-confirm-view'), + path('api/charge_create/', PaymentRequestCreateApiView.as_view(), name='charge-request-create-api-view'), ] diff --git a/apps/pg/views.py b/apps/pg/views.py index cf873f1..45899cb 100644 --- a/apps/pg/views.py +++ b/apps/pg/views.py @@ -10,31 +10,31 @@ from oauth2_provider.contrib.rest_framework import TokenHasScope, IsAuthenticate from rest_framework import generics from rest_framework.views import APIView -from .forms import ChargeRequestForm -from .models import ChargeRequest -from .serializers import ChargeRequestSerializer +from .forms import PaymentRequestForm +from .models import PaymentRequest, GatewayChoise +from .serializers import PaymentRequestSerializer from .constans import StateChoices from ..gooyal_oauth2.utils import get_application -class ChargeRequestCreateApiView(generics.CreateAPIView): +class PaymentRequestCreateApiView(generics.CreateAPIView): permission_classes = [IsAuthenticatedOrTokenHasScope] - serializer_class = ChargeRequestSerializer + serializer_class = PaymentRequestSerializer required_scopes = ['wallet.wallet:get_balance'] # TODO: is it good scope? - model = ChargeRequest + model = PaymentRequest def perform_create(self, serializer): user = self.request.user application = get_application(self.request) - charge_request: ChargeRequest = serializer.save(user=user, application=application) + charge_request: PaymentRequest = serializer.save(user=user, application=application) # TODO: web view # charge_request.charge_wallet_balance() @method_decorator(csrf_exempt, name='dispatch') -class ChargeRequestView(UpdateView): - template_name = 'pg/charge-request.html' - form_class = ChargeRequestForm +class PaymentRequestView(UpdateView): + template_name = 'pg/payment-request.html' + form_class = PaymentRequestForm # pk_url_kwarg = 'uuid' slug_field = 'uuid' slug_url_kwarg = 'uuid' @@ -42,36 +42,42 @@ class ChargeRequestView(UpdateView): def get_queryset(self): # return ChargeRequest.objects.all() # return Charge.objects.filter(state__in=[StateChoices.CREATED.value, StateChoices.SUCCESS.value]) - return ChargeRequest.objects.filter(state__in=[StateChoices.CREATED.value]) + return PaymentRequest.objects.filter(state__in=[StateChoices.CREATED.value]) def form_valid(self, form): self.object = form.save() - charge_request: ChargeRequest = self.get_object() + charge_request: PaymentRequest = self.get_object() + + if 'fake' in form.data: + print('fake') + + if 'pna' in form.data: + charge_request.set_gateway('pna') + + tool = charge_request.get_gateway_tools() + # try: - charge_request.request_sale() - pna_redirect_url = charge_request.get_pna_redirect_url() - if pna_redirect_url: - return HttpResponseRedirect(pna_redirect_url) + tool.make_request() + redirect_url = tool.get_redirect_url() + if redirect_url: + return HttpResponseRedirect(redirect_url) else: - messages.error(self.request, 'can not redirect to pna') + messages.error(self.request, 'can not redirect to payment gateway') # TODO: redirect to error page - return HttpResponseRedirect(reverse('pg:charge-confirm-view', kwargs={'uuid': charge_request.uuid})) + return HttpResponseRedirect(reverse('pg:payment-confirm-view', kwargs={'uuid': charge_request.uuid})) - def get_success_url(self): - return self.object.callback_url - # return reverse('pg:charge-request-view', kwargs={'pk': self.object.pk}) @method_decorator(csrf_exempt, name='dispatch') -class ChargeConfirmView(UpdateView): - template_name = 'pg/charge-confirm.html' - form_class = ChargeRequestForm +class PaymentConfirmView(UpdateView): + template_name = 'pg/payment-confirm.html' + form_class = PaymentRequestForm # pk_url_kwarg = 'uuid' slug_field = 'uuid' slug_url_kwarg = 'uuid' def get_queryset(self): - return ChargeRequest.objects.all() + return PaymentRequest.objects.all() # return Charge.objects.filter(state__in=[StateChoices.CREATED.value, StateChoices.SUCCESS.value]) def get(self, request, *args, **kwargs): @@ -99,16 +105,16 @@ class ChargeConfirmView(UpdateView): return response else: - # post = # post = print(dict(request.POST)) post_dict = dict(request.POST) if post_dict.get('status') in [0, '0', [0], ['0']]: try: - charge_request: ChargeRequest = self.get_object() - charge_request.charge_wallet_balance() - charge_request.confirm_sale() + payment_request: PaymentRequest = self.get_object() + tool = payment_request.get_gateway_tools() + tool.confirm_request() + payment_request.charge_wallet_balance() messages.success(self.request, 'شارژ انجام شد') except Exception as e: diff --git a/templates/pg/charge-confirm.html b/templates/pg/payment-confirm.html similarity index 100% rename from templates/pg/charge-confirm.html rename to templates/pg/payment-confirm.html diff --git a/templates/pg/charge-request.html b/templates/pg/payment-request.html similarity index 60% rename from templates/pg/charge-request.html rename to templates/pg/payment-request.html index c6e7631..3948d4a 100644 --- a/templates/pg/charge-request.html +++ b/templates/pg/payment-request.html @@ -20,11 +20,16 @@ {# #}
پرداخت با:
-
+
- +{#
#} + + +