Payment flow

This commit is contained in:
Kumi 2021-04-18 16:46:57 +02:00
parent 5c8c570589
commit e85074dc9f
14 changed files with 162 additions and 38 deletions

View file

@ -16,4 +16,4 @@ class InquiryProcessForm(forms.ModelForm):
class Meta: class Meta:
model = Inquiry model = Inquiry
fields = ["destination_radius", "arrival", "min_nights", "budget", "adults", "children", "comment"] fields = ["gateway"]

View file

@ -4,12 +4,14 @@ from django.contrib import messages
from django.urls import reverse from django.urls import reverse
from django.contrib.gis.geos import Point from django.contrib.gis.geos import Point
from django.contrib.gis.db.models.functions import Distance from django.contrib.gis.db.models.functions import Distance
from django.conf import settings
from public.mixins import InConstructionMixin from public.mixins import InConstructionMixin
from partners.mixins import PartnerProfileRequiredMixin from partners.mixins import PartnerProfileRequiredMixin
from localauth.helpers import name_to_coords from localauth.helpers import name_to_coords
from partners.models import Establishment from partners.models import Establishment
from payment.models import BillingAddress, Invoice, InvoiceItem, InvoicePayment from payment.models import BillingAddress, Invoice, InvoiceItem, InvoicePayment
from clients.models import ClientProfile
from .models import Inquiry, Offer from .models import Inquiry, Offer
from .forms import InquiryProcessForm from .forms import InquiryProcessForm
@ -59,7 +61,7 @@ class InquiryProcessView(InConstructionMixin, UpdateView):
return initial return initial
def form_valid(self, form): def form_valid(self, form):
profile = self.request.user.clientprofile profile, _ = ClientProfile.objects.get_or_create(user=self.request.user)
profile.first_name = form.cleaned_data["first_name"] profile.first_name = form.cleaned_data["first_name"]
profile.last_name = form.cleaned_data["last_name"] profile.last_name = form.cleaned_data["last_name"]
profile.street = form.cleaned_data["street"] profile.street = form.cleaned_data["street"]
@ -69,12 +71,30 @@ class InquiryProcessView(InConstructionMixin, UpdateView):
profile.country = form.cleaned_data["country"] profile.country = form.cleaned_data["country"]
profile.save() profile.save()
form.instance.client = profile
return super().form_valid(form)
def form_invalid(self, form, *args, **kwargs):
for field in form:
for error in field.errors:
messages.error(self.request, f"{field.name}: {error}")
return redirect("/")
def get_success_url(self): def get_success_url(self):
return reverse("auction:inquiry_payment", args=(self.object.uuid,)) return reverse("auction:inquiry_payment", args=(self.object.uuid,))
class InquiryPaymentView(InConstructionMixin, View): class InquiryPaymentView(InConstructionMixin, View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
inquiry = Inquiry.objects.get(uuid=kwargs["uuid"]) inquiry = Inquiry.objects.get(uuid=kwargs["uuid"])
try:
invoice = inquiry.invoice
except Invoice.DoesNotExist:
invoice = Invoice.from_inquiry(inquiry)
payment_url = InvoicePayment.from_invoice(invoice, inquiry.gateway)
return redirect(payment_url)
class OfferSelectionView(InConstructionMixin, DetailView): class OfferSelectionView(InConstructionMixin, DetailView):
model = Inquiry model = Inquiry

View file

@ -100,12 +100,17 @@ class PhoneMixin(models.Model):
class Meta: class Meta:
abstract = True abstract = True
class Profile(AddressMixin, PhoneMixin): class PersonMixin(models.Model):
user = models.OneToOneField(User, models.CASCADE)
company = models.CharField("Firma", max_length=64, null=True, blank=True) company = models.CharField("Firma", max_length=64, null=True, blank=True)
vat_id = models.CharField("UID-Nummer", max_length=32, null=True, blank=True) vat_id = models.CharField("UID-Nummer", max_length=32, null=True, blank=True)
first_name = models.CharField("Vorname", max_length=64) first_name = models.CharField("Vorname", max_length=64)
last_name = models.CharField("Nachname", max_length=64) last_name = models.CharField("Nachname", max_length=64)
class Meta:
abstract = True
class Profile(PersonMixin, AddressMixin, PhoneMixin):
user = models.OneToOneField(User, models.CASCADE)
verified = models.BooleanField(default=False) verified = models.BooleanField(default=False)
enabled = models.BooleanField(default=True) enabled = models.BooleanField(default=True)

View file

@ -29,3 +29,9 @@ AWS_STORAGE_BUCKET_NAME = "AWS Bucket"
# Countries the app can be used in (currently no more than 5 due to Google Maps restrictions) # Countries the app can be used in (currently no more than 5 due to Google Maps restrictions)
JOKER_COUNTRIES = ["AT"] JOKER_COUNTRIES = ["AT"]
# Currency to use
CURRENCY_SYMBOL = ""
CURRENCY_CODE = "EUR"
CURRENCY_NAME = "Euro"

View file

@ -1,15 +1,30 @@
from payment.models import InvoicePayment, Invoice from payment.models import InvoicePayment, Invoice
from payment.signals import initiate_payment
from django.db import models from django.db import models
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.dispatch import receiver
import uuid import uuid
class DemoInvoicePayment(InvoicePayment): class DemoInvoicePayment(InvoicePayment):
@property @property
def gateway(self): def gateway(self):
return "Paypal" return "Demo"
@staticmethod @classmethod
def initiate(invoice): def initiate(cls, invoice):
self.objects.create(invoice=invoice, amount=invoice.amount, gateway_id=uuid.uuid4()) payment = cls.objects.create(invoice=invoice, amount=invoice.balance * -1, gateway_id=uuid.uuid4())
invoice.generate_invoice()
return reverse_lazy("payment:status", args=[payment.uuid])
@property
def status(self):
return 0
@receiver(initiate_payment)
def from_signal(sender, **kwargs):
if kwargs["gateway"] == "demo":
return {"redirect": DemoInvoicePayment.initiate(kwargs["invoice"])}
else:
return {}

View file

@ -2,6 +2,7 @@ from django.db import models
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.utils import timezone from django.utils import timezone
from django.conf import settings
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
@ -13,27 +14,38 @@ from django_countries.fields import CountryField
from io import BytesIO from io import BytesIO
import uuid
import urllib.request import urllib.request
from dbsettings.functions import getValue from dbsettings.functions import getValue
from .functions import invoice_upload_path from .functions import invoice_upload_path
from .signals import initiate_payment
from auction.models import Inquiry from auction.models import Inquiry
from localauth.models import PersonMixin, AddressMixin
class BillingAddress(models.Model): class BillingAddress(PersonMixin, AddressMixin):
user = models.ForeignKey(get_user_model(), models.CASCADE) user = models.ForeignKey(get_user_model(), models.CASCADE)
company = models.CharField(max_length=64, null=True, blank=True)
vat_id = models.CharField(max_length=32, null=True, blank=True) @classmethod
first_name = models.CharField(max_length=64) def from_profile(cls, profile):
last_name = models.CharField(max_length=64) return cls.objects.create(
street = models.CharField(max_length=128) company = profile.company,
city = models.CharField(max_length=64) vat_id = profile.vat_id,
zip = models.CharField(max_length=16) first_name = profile.first_name,
state = models.CharField(max_length=32, null=True, blank=True) last_name = profile.last_name,
country = CountryField() street = profile.street,
city = profile.city,
zip = profile.zip,
state = profile.state,
country = profile.country,
user = profile.user
)
class Invoice(models.Model): class Invoice(models.Model):
uuid = models.UUIDField(default=uuid.uuid4)
user = models.ForeignKey(get_user_model(), models.PROTECT) user = models.ForeignKey(get_user_model(), models.PROTECT)
billing_address = models.ForeignKey(BillingAddress, models.PROTECT) billing_address = models.ForeignKey(BillingAddress, models.PROTECT)
currency = models.CharField(max_length=3) currency = models.CharField(max_length=3)
@ -45,7 +57,7 @@ class Invoice(models.Model):
def price_net(self): def price_net(self):
price = 0 price = 0
for item in self.invoiceitem_set().all(): for item in self.invoiceitem_set.all():
price += item.net_total price += item.net_total
return price return price
@ -67,15 +79,17 @@ class Invoice(models.Model):
return False return False
@property @property
def is_paid(self): def balance(self):
paid_amount = 0 paid_amount = 0
for payment in self.invoicepayment_set().all(): for payment in self.invoicepayment_set.all():
paid_amount += payment.amount paid_amount += payment.amount
if paid_amount >= price_gross: return paid_amount - self.price_gross
return True
return False @property
def is_paid(self):
return self.balance >= 0
def generate_invoice(self): def generate_invoice(self):
output = BytesIO() output = BytesIO()
@ -116,21 +130,21 @@ class Invoice(models.Model):
"state": profile.state, "state": profile.state,
"country": profile.country, "country": profile.country,
"post_code": profile.zip, "post_code": profile.zip,
"vat_tax_number": profile.vat_id,
"email": self.user.email, "email": self.user.email,
}.items() if value} }.items() if value}
doc.client_info = ClientInfo(**client_data) doc.client_info = ClientInfo(**client_data)
for item in self.invoiceitem_set().all(): for item in self.invoiceitem_set.all():
doc.add_item(Item(item.name, item.description, item.count, item.net_each)) doc.add_item(Item(item.name, item.description, item.count, item.net_each))
doc.set_item_tax_rate(self.tax_rate) doc.set_item_tax_rate(self.tax_rate)
for payment in self.invoicepayment_set().all(): for payment in self.invoicepayment_set.all():
doc.add_transaction(Transaction(payment.gateway, payment.gateway_id, payment.timestamp, payment.amount)) doc.add_transaction(Transaction(payment.gateway, payment.gateway_id, payment.timestamp, payment.amount))
bottom_tip = (f"{ self.payment_instructions }<br /><br />" if self.payment_instructions else "") + getValue("billing.bottom_tip", "") bottom_tip = ("Kunden-UID: %s<br /><br />" % profile.vat_id) if profile.vat_id else ""
bottom_tip += (f"{ self.payment_instructions }<br /><br />" if self.payment_instructions else "") + getValue("billing.bottom_tip", "")
bottom_tip += "<br /><br />" if bottom_tip else "" bottom_tip += "<br /><br />" if bottom_tip else ""
bottom_tip += "Rechnung erstellt: %s" % str(timezone.now()) bottom_tip += "Rechnung erstellt: %s" % str(timezone.now())
@ -141,6 +155,26 @@ class Invoice(models.Model):
self.invoice = ContentFile(output.getvalue(), "invoice.pdf") self.invoice = ContentFile(output.getvalue(), "invoice.pdf")
self.save() self.save()
@classmethod
def from_inquiry(cls, inquiry):
invoice = cls.objects.create(
user = inquiry.client.user,
billing_address = BillingAddress.from_profile(inquiry.client),
currency = settings.CURRENCY_CODE,
tax_rate = 0,
inquiry = inquiry
)
InvoiceItem.objects.create(
invoice = invoice,
name = "Sicherheitsleistung",
description = "Rückzahlbare Sicherheitsleistung zu JourneyJoker-Anfrage #%i" % inquiry.id,
count = 1,
net_each = inquiry.budget
)
return invoice
class InvoiceItem(models.Model): class InvoiceItem(models.Model):
invoice = models.ForeignKey(Invoice, models.CASCADE) invoice = models.ForeignKey(Invoice, models.CASCADE)
name = models.CharField(max_length=64) name = models.CharField(max_length=64)
@ -148,7 +182,12 @@ class InvoiceItem(models.Model):
count = models.IntegerField() count = models.IntegerField()
net_each = models.DecimalField(max_digits=11, decimal_places=2) net_each = models.DecimalField(max_digits=11, decimal_places=2)
@property
def net_total(self):
return self.net_each * self.count
class InvoicePayment(PolymorphicModel): class InvoicePayment(PolymorphicModel):
uuid = models.UUIDField(default=uuid.uuid4)
invoice = models.ForeignKey(Invoice, models.PROTECT) invoice = models.ForeignKey(Invoice, models.PROTECT)
amount = models.DecimalField(max_digits=9, decimal_places=2) amount = models.DecimalField(max_digits=9, decimal_places=2)
gateway_id = models.CharField(max_length=256) gateway_id = models.CharField(max_length=256)
@ -158,6 +197,23 @@ class InvoicePayment(PolymorphicModel):
def gateway(self): def gateway(self):
raise NotImplementedError("%s does not implement gateway" % type(self)) raise NotImplementedError("%s does not implement gateway" % type(self))
@property
def status(self):
raise NotImplementedError("%s does not implement status" % type(self))
@classmethod @classmethod
def initiate(cls, invoice): def initiate(cls, invoice):
raise NotImplementedError("%s does not implement initiate()" % cls.__name__) raise NotImplementedError("%s does not implement initiate()" % cls.__name__)
@classmethod
def from_invoice(cls, invoice, gateway):
if invoice.balance < 0:
responses = initiate_payment.send_robust(sender=cls, invoice=invoice, gateway=gateway)
for handler, response in responses:
try:
return response["redirect"]
except:
continue
return False

3
payment/signals.py Normal file
View file

@ -0,0 +1,3 @@
import django.dispatch
initiate_payment = django.dispatch.Signal()

View file

@ -1,8 +1,11 @@
from django.urls import path, include from django.urls import path, include
from .views import PaymentStatusView
app_name = "payment" app_name = "payment"
urlpatterns = [ urlpatterns = [
path('gateways/paypal/', include("payment.paypal.urls"), name="paypal"), path('gateways/paypal/', include("payment.paypal.urls"), name="paypal"),
path('gateways/sepa/', include("payment.sepa.urls"), name="sepa"), path('gateways/sepa/', include("payment.sepa.urls"), name="sepa"),
path('status/<slug:uuid>/', PaymentStatusView.as_view(), name="status"),
] ]

View file

@ -0,0 +1,11 @@
from django.views.generic import DetailView
from django.shortcuts import get_object_or_404
from .models import InvoicePayment
class PaymentStatusView(DetailView):
model = InvoicePayment
template_name = "payment/status.html"
def get_object(self):
return get_object_or_404(InvoicePayment, uuid=self.kwargs["uuid"])

View file

@ -1,3 +1,3 @@
function setPaymentMethod() { function setPaymentMethod() {
$('#id_payment').val($(".nav-link.active")[0].href.split("#tab-")[1]) $('#id_gateway').val($(".nav-link.active")[0].href.split("#tab-")[1])
} }

View file

@ -0,0 +1 @@
var language="DE";

View file

@ -90,7 +90,7 @@
<div class="col-12 col-md-12 col-lg-7 col-xl-8 content-side"> <div class="col-12 col-md-12 col-lg-7 col-xl-8 content-side">
<form class="lg-booking-form" id="frm_booking" name="frm_booking" method="post" action="{% url "auction:inquiry_payment" object.uuid %}"> <form class="lg-booking-form" id="frm_booking" name="frm_booking" method="post" %}">
{% csrf_token %} {% csrf_token %}
{% if not request.user.is_authenticated %} {% if not request.user.is_authenticated %}
<div class="lg-booking-form-heading"> <div class="lg-booking-form-heading">
@ -295,7 +295,7 @@
<label><input type="checkbox" {% if request.user.is_authenticated %}required{% else %}disabled{% endif %} id="id_terms" name="terms"> {% trans "Ich erkläre mich einverstanden mit den" %} <a href="#">{% trans "Allgemeinen Geschäftsbedingungen" %}</a> {% trans "und der" %} <a href="#">{% trans "Datenschutzerklärung" %}</a>.</label> <label><input type="checkbox" {% if request.user.is_authenticated %}required{% else %}disabled{% endif %} id="id_terms" name="terms"> {% trans "Ich erkläre mich einverstanden mit den" %} <a href="#">{% trans "Allgemeinen Geschäftsbedingungen" %}</a> {% trans "und der" %} <a href="#">{% trans "Datenschutzerklärung" %}</a>.</label>
</div><!-- end checkbox --> </div><!-- end checkbox -->
<div class="col-md-12 text-center" id="result_msg"></div> <div class="col-md-12 text-center" id="result_msg"></div>
<input type="hidden" id="id_payment" name="payment"> <input type="hidden" id="id_gateway" name="gateway">
<button type="submit" onclick="setPaymentMethod(); return true;" class="btn btn-orange" name="submit" id="submit">{% trans "Zahlung durchführen" %}</button> <button type="submit" onclick="setPaymentMethod(); return true;" class="btn btn-orange" name="submit" id="submit">{% trans "Zahlung durchführen" %}</button>
</form> </form>

View file

@ -340,7 +340,7 @@
</div> </div>
<!-- Page Scripts Starts --> <!-- Page Scripts Starts -->
<script>var language="{{ request.LANGUAGE_CODE }}";</script> <script src="{% static "frontend/js/language.js" %}"></script>
<script src="{% static "vendor/js/jquery-3.3.1.min.js" %}"></script> <script src="{% static "vendor/js/jquery-3.3.1.min.js" %}"></script>
<script src="{% static "vendor/js/jquery.magnific-popup.min.js" %}"></script> <script src="{% static "vendor/js/jquery.magnific-popup.min.js" %}"></script>
<script src="{% static "vendor/js/jquery.mCustomScrollbar.concat.min.js" %}"></script> <script src="{% static "vendor/js/jquery.mCustomScrollbar.concat.min.js" %}"></script>

View file

@ -9,7 +9,7 @@
<div class="col-md-12 col-lg-8 col-lg-offset-2"> <div class="col-md-12 col-lg-8 col-lg-offset-2">
<div class="payment-success-text"> <div class="payment-success-text">
<h2>{% trans "Zahlung" %} {% if object.status == 0 %}{% trans "erfolgreich" %}{% elif object.status == -1 %}{% trans "autorisiert" %}{% endif %}</h2> <h2>{% trans "Zahlung" %} {% if object.status == 0 %}{% trans "erfolgreich" %}{% elif object.status == -1 %}{% trans "autorisiert" %}{% endif %}</h2>
<p>{% blocktrans with currency=object.invoice.currency|upper amount=object.invoice.amount %}Die Zahlung über {{ currency }} {{ amount }} wurde{% endblocktrans %} {% if object.status == 0 %}{% trans "erfolgreich durchgeführt" %}{% elif object.status == -1 %}{% trans "autorisiert" %}{% endif %}</p> <p>{% blocktrans with currency=object.invoice.currency|upper amount=object.amount %}Die Zahlung über {{ currency }} {{ amount }} wurde{% endblocktrans %} {% if object.status == 0 %}{% trans "erfolgreich durchgeführt" %}{% elif object.status == -1 %}{% trans "autorisiert" %}{% endif %}</p>
<span><i class="fa fa-check-circle"></i></span> <span><i class="fa fa-check-circle"></i></span>
<div class="table-responsive"> <div class="table-responsive">
@ -22,16 +22,20 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for line in object.invoice.invoiceitem_set.all %}
<tr> <tr>
<td><span><i class="fa fa-building"></i></span>{% trans "Gebot" %}</td> <td><span><i class="fa fa-building"></i></span>{% if object.invoice.inquiry %}{% trans "Gebot" %}{% else %}{% trans "Gebühren" %}{% endif %}</td>
<td>{{ object.invoice.destination_name }}<span class="t-date">{{ object.first_date|date:"SHORT_DATE_FORMAT" }} {{ object.last_date|date:"SHORT_DATE_FORMAT" }}</span></td> <td>{{ line.description }}</td>
<td>{{ object.invoice.currency|upper }} {{ object.invoice.amount }}</td> <td>{{ object.invoice.currency|upper }} {{ line.net_total }}</td>
</tr> </tr>
{% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
<a href="{% url "auction:offer_selection" object.invoice.inquiry.uuid %}" class="btn btn-orange">Angebote erhalten!</a><br /><br />
<a href="{{ object.invoice.invoice.url }}" class="btn">PDF-Beleg herunterladen</a>
{% if object.status == -1 %} {% if object.status == -1 %}
<p>{% trans "Die Zahlung wurde autorisiert." %} <a href="/">{% trans "Was bedeutet das?" %}</a></p> <p>{% trans "Die Zahlung wurde autorisiert." %} <a href="#">{% trans "Was bedeutet das?" %}</a></p>
{% endif %} {% endif %}
<p>{% trans "Die Zahlungsdaten wurden auch per E-Mail versendet." %}</p> <p>{% trans "Die Zahlungsdaten wurden auch per E-Mail versendet." %}</p>
</div> </div>