From d2792b8aa36856915edcb8c49d70e3f7b73555aa Mon Sep 17 00:00:00 2001 From: Klaus-Uwe Mitterer Date: Thu, 10 Jun 2021 13:18:52 +0200 Subject: [PATCH] Refactor payment app Generate invoice from HTML Remove now unused dependency on PyInvoice --- frontend/views.py | 2 +- localauth/models.py | 24 +-- payment/models.py | 230 ----------------------------- payment/models/__init__.py | 4 + payment/models/billingaddress.py | 22 +++ payment/models/invoice.py | 116 +++++++++++++++ payment/models/invoiceitem.py | 14 ++ payment/models/invoicepayment.py | 43 ++++++ payment/pdfviews.py | 9 ++ payment/views.py | 2 +- requirements.txt | 1 - static/payment/invoice/invoice.css | 114 ++++++++++++++ templates/payment/invoice.html | 145 ++++++++++++++++++ 13 files changed, 484 insertions(+), 242 deletions(-) delete mode 100644 payment/models.py create mode 100644 payment/models/__init__.py create mode 100644 payment/models/billingaddress.py create mode 100644 payment/models/invoice.py create mode 100644 payment/models/invoiceitem.py create mode 100644 payment/models/invoicepayment.py create mode 100644 payment/pdfviews.py create mode 100644 static/payment/invoice/invoice.css create mode 100644 templates/payment/invoice.html diff --git a/frontend/views.py b/frontend/views.py index 3e164b6..5f4aef9 100644 --- a/frontend/views.py +++ b/frontend/views.py @@ -7,7 +7,7 @@ class Error404View(TemplateView): template_name = "frontend/404.html" class DemoTemplateView(TemplateView): - template_name = "mail/verify.html" + template_name = "payment/invoice.html" class ImpressumView(TemplateView): template_name = "frontend/impressum.html" diff --git a/localauth/models.py b/localauth/models.py index c535bc7..d9079ef 100644 --- a/localauth/models.py +++ b/localauth/models.py @@ -117,6 +117,21 @@ class PersonMixin(models.Model): first_name = models.CharField("Vorname", max_length=64) last_name = models.CharField("Nachname", max_length=64) + @property + def full_name(self): + name = self.full_name_only + + if self.company: + name += f" ({self.company})" + + return name + + @property + def full_name_only(self): + name = " ".join([self.first_name, self.last_name]) + + return name + class Meta: abstract = True @@ -125,15 +140,6 @@ class Profile(PersonMixin, AddressMixin, PhoneMixin): verified = models.BooleanField(default=False) enabled = models.BooleanField(default=True) - @property - def full_name(self): - name = " ".join([self.first_name, self.last_name]) - - if self.company: - name += f" ({self.company})" - - return name - class Meta: abstract = True diff --git a/payment/models.py b/payment/models.py deleted file mode 100644 index 60979e5..0000000 --- a/payment/models.py +++ /dev/null @@ -1,230 +0,0 @@ -from django.db import models -from django.contrib.auth import get_user_model -from django.core.files.base import ContentFile -from django.utils import timezone -from django.conf import settings - -from dateutil.relativedelta import relativedelta - -from pyinvoice.models import InvoiceInfo, ServiceProviderInfo, ClientInfo, Item, Transaction, PDFInfo -from pyinvoice.templates import SimpleInvoice - -from polymorphic.models import PolymorphicModel -from django_countries.fields import CountryField - -from io import BytesIO - -import uuid - -import urllib.request - -from dbsettings.functions import getValue - -from .functions import invoice_upload_path -from .signals import initiate_payment - -from auction.models import Inquiry -from localauth.models import PersonMixin, AddressMixin - -class BillingAddress(PersonMixin, AddressMixin): - user = models.ForeignKey(get_user_model(), models.CASCADE) - - @classmethod - def from_profile(cls, profile): - return cls.objects.create( - company = profile.company, - vat_id = profile.vat_id, - first_name = profile.first_name, - last_name = profile.last_name, - street = profile.street, - city = profile.city, - zip = profile.zip, - state = profile.state, - country = profile.country, - user = profile.user - ) - -class Invoice(models.Model): - uuid = models.UUIDField(default=uuid.uuid4) - user = models.ForeignKey(get_user_model(), models.PROTECT) - billing_address = models.ForeignKey(BillingAddress, models.PROTECT) - currency = models.CharField(max_length=3) - tax_rate = models.DecimalField(max_digits=4, decimal_places=2) - invoice = models.FileField(null=True, blank=True, upload_to=invoice_upload_path) - inquiry = models.OneToOneField(Inquiry, null=True, blank=True, on_delete=models.SET_NULL) - created = models.DateTimeField(auto_now_add=True) - - @property - def price_net(self): - price = 0 - - for item in self.invoiceitem_set.all(): - price += item.net_total - - return price - - @property - def tax(self): - return round(self.price_net * self.tax_rate / 100, 2) - - @property - def price_gross(self): - return self.price_net + self.tax - - @property - def payment_instructions(self): - return "Alle Preise in %s." % self.currency - - @property - def url(self): - return False - - @property - def balance(self): - paid_amount = 0 - - for payment in self.invoicepayment_set.all(): - paid_amount += payment.amount - - return paid_amount - self.price_gross - - @property - def is_paid(self): - return self.balance >= 0 - - def finalize(self, *args, **kwargs): - if self.is_paid: - try: - self.inquiry.process_payment(*args, **kwargs) - except Inquiry.DoesNotExist: - pass - - self.generate_invoice() - - def generate_invoice(self): - output = BytesIO() - - pdfinfo = PDFInfo(title='Rechnung', author='Kumi Media Ltd.', subject='Rechnung') - doc = SimpleInvoice(output, pdfinfo) - - doc.is_paid = self.is_paid - - doc.invoice_info = InvoiceInfo("%s%i" % (getValue("billing.prefix", ""), self.id), timezone.now().date(), timezone.now().date()) - - provider_data = { key: value for key, value in { - "name": getValue("billing.provider.name", False), - "street": getValue("billing.provider.street", False), - "city": getValue("billing.provider.city", False), - "state": getValue("billing.provider.state", False), - "country": getValue("billing.provider.country", False), - "post_code": getValue("billing.provider.zip", False), - "vat_tax_number": getValue("billing.provider.vat_id", False) - }.items() if value} - - doc.service_provider_info = ServiceProviderInfo(**provider_data) - - logo_url = getValue("billing.logo_url", False) - if logo_url: - try: - logo = BytesIO(urllib.request.urlopen(logo_url).read()) - doc.logo(logo) - except: - pass - - profile = self.user.clientprofile if self.inquiry else self.user.partnerprofile - - client_data = { key: value for key, value in { - "name": profile.full_name, - "street": profile.street, - "city": profile.city, - "state": profile.state, - "country": profile.country, - "post_code": profile.zip, - "email": self.user.email, - }.items() if value} - - doc.client_info = ClientInfo(**client_data) - - for item in self.invoiceitem_set.all(): - doc.add_item(Item(item.name, item.description, item.count, item.net_each)) - - doc.set_item_tax_rate(self.tax_rate) - - for payment in self.invoicepayment_set.all(): - doc.add_transaction(Transaction(payment.gateway, payment.gateway_id, payment.timestamp, payment.amount)) - - bottom_tip = ("Kunden-UID: %s

" % profile.vat_id) if profile.vat_id else "" - bottom_tip += (f"{ self.payment_instructions }

" if self.payment_instructions else "") + getValue("billing.bottom_tip", "") - bottom_tip += "

" if bottom_tip else "" - bottom_tip += "Rechnung erstellt: %s" % str(timezone.now()) - - doc.set_bottom_tip(bottom_tip) - - doc.finish() - - self.invoice = ContentFile(output.getvalue(), "invoice.pdf") - 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): - invoice = models.ForeignKey(Invoice, models.CASCADE) - name = models.CharField(max_length=64) - description = models.CharField(max_length=256, null=True, blank=True) - count = models.IntegerField() - net_each = models.DecimalField(max_digits=11, decimal_places=2) - - @property - def net_total(self): - return self.net_each * self.count - -class InvoicePayment(PolymorphicModel): - uuid = models.UUIDField(default=uuid.uuid4) - invoice = models.ForeignKey(Invoice, models.PROTECT) - amount = models.DecimalField(max_digits=9, decimal_places=2) - gateway_id = models.CharField(max_length=256) - timestamp = models.DateTimeField(default=timezone.now) - - @property - def gateway(self): - raise NotImplementedError("%s does not implement gateway" % type(self)) - - @property - def status(self): - raise NotImplementedError("%s does not implement status" % type(self)) - - @classmethod - def initiate(cls, invoice): - raise NotImplementedError("%s does not implement initiate()" % cls.__name__) - - def finalize(self, *args, **kwargs): - return self.invoice.finalize(*args, **kwargs) - - @classmethod - def from_invoice(cls, invoice, gateway): - if not invoice.is_paid: - responses = initiate_payment.send_robust(sender=cls, invoice=invoice, gateway=gateway) - for handler, response in responses: - try: - return response["redirect"] - except: - continue - return False \ No newline at end of file diff --git a/payment/models/__init__.py b/payment/models/__init__.py new file mode 100644 index 0000000..de5d325 --- /dev/null +++ b/payment/models/__init__.py @@ -0,0 +1,4 @@ +from .billingaddress import BillingAddress +from .invoice import Invoice +from .invoiceitem import InvoiceItem +from .invoicepayment import InvoicePayment \ No newline at end of file diff --git a/payment/models/billingaddress.py b/payment/models/billingaddress.py new file mode 100644 index 0000000..1437924 --- /dev/null +++ b/payment/models/billingaddress.py @@ -0,0 +1,22 @@ +from django.db import models +from django.contrib.auth import get_user_model + +from localauth.models import PersonMixin, AddressMixin + +class BillingAddress(PersonMixin, AddressMixin): + user = models.ForeignKey(get_user_model(), models.CASCADE) + + @classmethod + def from_profile(cls, profile): + return cls.objects.create( + company = profile.company, + vat_id = profile.vat_id, + first_name = profile.first_name, + last_name = profile.last_name, + street = profile.street, + city = profile.city, + zip = profile.zip, + state = profile.state, + country = profile.country, + user = profile.user + ) \ No newline at end of file diff --git a/payment/models/invoice.py b/payment/models/invoice.py new file mode 100644 index 0000000..082ee3c --- /dev/null +++ b/payment/models/invoice.py @@ -0,0 +1,116 @@ +from django.db import models +from django.contrib.auth import get_user_model +from django.core.files.base import ContentFile +from django.utils import timezone + +import uuid + +from dbsettings.functions import getValue + +from auction.models import Inquiry + +from .billingaddress import BillingAddress + +from ..functions import invoice_upload_path +from ..pdfviews import InvoicePDFView + +class InvoiceTypeChoices(models.IntegerChoices): + INVOICE = (0, "Rechnung") + DEPOSIT = (1, "Sicherheitsleistung") + +class Invoice(models.Model): + uuid = models.UUIDField(default=uuid.uuid4) + user = models.ForeignKey(get_user_model(), models.PROTECT) + billing_address = models.ForeignKey(BillingAddress, models.PROTECT) + currency = models.CharField(max_length=3) + tax_rate = models.DecimalField(max_digits=4, decimal_places=2) + invoice = models.FileField(null=True, blank=True, upload_to=invoice_upload_path) + inquiry = models.OneToOneField(Inquiry, null=True, blank=True, on_delete=models.SET_NULL) + created = models.DateTimeField(auto_now_add=True) + type = models.IntegerField(choices=InvoiceTypeChoices.choices) + + @property + def price_net(self): + price = 0 + + for item in self.invoiceitem_set.all(): + price += item.net_total + + return price + + @property + def tax(self): + return round(self.price_net * self.tax_rate / 100, 2) + + @property + def price_gross(self): + return self.price_net + self.tax + + @property + def payment_instructions(self): + return None + + @property + def url(self): + try: + return self.invoice.url + except: + return False + + @property + def balance(self): + paid_amount = 0 + + for payment in self.invoicepayment_set.all(): + paid_amount += payment.amount + + return paid_amount - self.price_gross + + @property + def is_paid(self): + return self.balance >= 0 + + def finalize(self, *args, **kwargs): + if self.is_paid: + try: + self.inquiry.process_payment(*args, **kwargs) + except Inquiry.DoesNotExist: + pass + + self.generate_invoice() + + def generate_invoice(self): + view = InvoicePDFView() + + bottom_tip = (f"{ self.payment_instructions }

" if self.payment_instructions else "") + getValue("billing.bottom_tip", "") + bottom_tip += "
" if bottom_tip else "" + bottom_tip += "Dokument erstellt: %s" % str(timezone.now()) + + args = { + "type": InvoiceTypeChoices._value2label_map_[self.type], + "object": self, + "bottom_tip": bottom_tip + } + + self.invoice = ContentFile(view.render(**args), "invoice.pdf") + 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 = "SL", + description = "Rückzahlbare Sicherheitsleistung zu JourneyJoker-Anfrage #%i" % inquiry.id, + count = 1, + net_each = inquiry.budget + ) + + return invoice \ No newline at end of file diff --git a/payment/models/invoiceitem.py b/payment/models/invoiceitem.py new file mode 100644 index 0000000..b5ca2e7 --- /dev/null +++ b/payment/models/invoiceitem.py @@ -0,0 +1,14 @@ +from django.db import models + +from .invoice import Invoice + +class InvoiceItem(models.Model): + invoice = models.ForeignKey(Invoice, models.CASCADE) + name = models.CharField(max_length=64) + description = models.CharField(max_length=256, null=True, blank=True) + count = models.IntegerField() + net_each = models.DecimalField(max_digits=11, decimal_places=2) + + @property + def net_total(self): + return self.net_each * self.count \ No newline at end of file diff --git a/payment/models/invoicepayment.py b/payment/models/invoicepayment.py new file mode 100644 index 0000000..0f33f94 --- /dev/null +++ b/payment/models/invoicepayment.py @@ -0,0 +1,43 @@ +from django.db import models +from django.utils import timezone + +import uuid + +from polymorphic.models import PolymorphicModel + +from .invoice import Invoice + +from ..signals import initiate_payment + +class InvoicePayment(PolymorphicModel): + uuid = models.UUIDField(default=uuid.uuid4) + invoice = models.ForeignKey(Invoice, models.PROTECT) + amount = models.DecimalField(max_digits=9, decimal_places=2) + gateway_id = models.CharField(max_length=256) + timestamp = models.DateTimeField(default=timezone.now) + + @property + def gateway(self): + raise NotImplementedError("%s does not implement gateway" % type(self)) + + @property + def status(self): + raise NotImplementedError("%s does not implement status" % type(self)) + + @classmethod + def initiate(cls, invoice): + raise NotImplementedError("%s does not implement initiate()" % cls.__name__) + + def finalize(self, *args, **kwargs): + return self.invoice.finalize(*args, **kwargs) + + @classmethod + def from_invoice(cls, invoice, gateway): + if not invoice.is_paid: + responses = initiate_payment.send_robust(sender=cls, invoice=invoice, gateway=gateway) + for handler, response in responses: + try: + return response["redirect"] + except: + continue + return False \ No newline at end of file diff --git a/payment/pdfviews.py b/payment/pdfviews.py new file mode 100644 index 0000000..abf0d2f --- /dev/null +++ b/payment/pdfviews.py @@ -0,0 +1,9 @@ +from pdf.views import PDFView + +class InvoicePDFView(PDFView): + template_name = "payment/invoice.html" + + def get_context_data(self, **kwargs): + kwargs.setdefault('type', "Rechnung") + kwargs.setdefault('title', f'{kwargs["type"]} #{kwargs["object"].id}') + return super().get_context_data(**kwargs) \ No newline at end of file diff --git a/payment/views.py b/payment/views.py index 5ffb56b..ec0d9da 100644 --- a/payment/views.py +++ b/payment/views.py @@ -1,7 +1,7 @@ from django.views.generic import DetailView from django.shortcuts import get_object_or_404 -from .models import InvoicePayment +from .models.invoicepayment import InvoicePayment class PaymentStatusView(DetailView): model = InvoicePayment diff --git a/requirements.txt b/requirements.txt index 7e6d184..a9396d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,4 +24,3 @@ python-magic bs4 django-starfield pdfkit -git+https://kumig.it/kumisystems/PyInvoice diff --git a/static/payment/invoice/invoice.css b/static/payment/invoice/invoice.css new file mode 100644 index 0000000..e383bb4 --- /dev/null +++ b/static/payment/invoice/invoice.css @@ -0,0 +1,114 @@ +.invoice-box { + max-width: 800px; + margin: auto; + padding: 30px; + border: 1px solid #eee; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.15); + font-size: 16px; + line-height: 24px; + font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; + color: #555; +} + +.invoice-box table { + width: 100%; + line-height: inherit; + text-align: left; +} + +.invoice-box table td { + padding: 5px; + vertical-align: top; +} + +.invoice-box table tr td:first-child { + text-align: left !important; +} + +.invoice-box table tr td:last-child { + text-align: right; +} + +.invoice-box table tr.top table td { + padding-bottom: 20px; +} + +.invoice-box table tr.top table td.title { + font-size: 45px; + line-height: 45px; + color: #333; +} + +table:not(:first) { + padding-top: 20px; +} + +.invoice-box table tr.information table td { + padding-bottom: 40px; +} + +.invoice-box table tr.heading td { + background: #eee; + border-bottom: 1px solid #ddd; + font-weight: bold; +} + +.invoice-box table tr.details td { + padding-bottom: 20px; +} + +.invoice-box table tr.item td { + border-bottom: 1px solid #eee; +} + +.invoice-box table tr.item.last td { + border-bottom: none; +} + +.invoice-box table tr.tax td { + border-top: 2px solid #eee; +} + +.invoice-box table tr.tax.last td { + border-top: none; +} + +.invoice-box table tr.total td:last-child { + border-top: 2px solid #eee; +} + +.invoice-box table tr.total { + font-weight: bold; +} + +.invoice-box table tr.details.balance { + font-weight: bold; +} + +@media only screen and (max-width: 600px) { + .invoice-box table tr.top table td { + width: 100%; + display: block; + text-align: center; + } + + .invoice-box table tr.information table td { + width: 100%; + display: block; + text-align: center; + } +} + +/** RTL **/ +.invoice-box.rtl { + direction: rtl; + font-family: Tahoma, 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; +} + +.invoice-box.rtl table { + text-align: right; +} + +.invoice-box.rtl table tr td:nth-child(2) { + text-align: left; +} \ No newline at end of file diff --git a/templates/payment/invoice.html b/templates/payment/invoice.html new file mode 100644 index 0000000..57bd0a6 --- /dev/null +++ b/templates/payment/invoice.html @@ -0,0 +1,145 @@ +{% load static %} +{% load dbsetting %} + + + + + {{ title }} + + + + +
+ + + + + + + + +
+ + + + + + +
+ + + {{ type }} #: {{ object.id }}
+ Datum: {{ object.created }} +
+
+ + + + + + +
+ Empfänger:
+ {% if object.billing_address.company %}{{ object.billing_address.company }}
{% endif %} + {{ object.billing_address.full_name }}
+ {{ object.billing_address.street }}
+ {{ object.billing_address.zip }} {{ object.billing_address.city }}
+ {{ object.billing_address.country.name }}{% if object.billing_address.vat_id %}
+ UID: {{ object.billing_address.vat_id }}{% endif %} +
+ Absender: + {% dbsetting "billing.provider.name" "" %}
+ {% dbsetting "billing.provider.street" "" %}
+ {% dbsetting "billing.provider.zip" "" %} {% dbsetting "billing.provider.city" "" %}
+ {% dbsetting "billing.provider.country" "" %}{% dbsetting "billing.provider.vat_id" "" as vat_id %}{% if vat_id %}
+ UID: {{ vat_id }}{% endif %} +
+
+ +

{{ title }}

+ + + + + + + + + + + {% for item in object.invoiceitem_set.all %} + + + + + + + + {% endfor %} + + + + + + + + + + + + + + + + + + + + + + + + + +
ProduktBeschreibungAnzahlEinzelpreisGesamtpreis
{{ item.name }}{{ item.description }}{{ item.count }}{{ object.currency }} {{ item.net_each }}{{ object.currency }} {{ item.net_total }}
Zwischensumme{{ object.currency }} {{ object.price_net }}
{{ object.tax_rate }}% USt{{ object.currency }} {{ object.tax }}
Gesamtsumme{{ object.currency }} {{ object.price_gross }}
+ + + + + + + + + + {% for payment in object.invoicepayment_set.all %} + + + + + + + {% endfor %} + + {% if not object.is_paid %} + + + + + + + {% endif %} + +
ZahlungsmethodeZahlungs-IDZahlungsdatumBetrag
{{ payment.gateway }}{{ payment.gateway_id }}{{ payment.timestamp }}{{ object.currency }} {{ payment.amount }}
Offener Betrag{{ object.currency }} {{ object.balance }}
+ + + + + + + + + +
Weitere Informationen
{% autoescape off %}{{ bottom_tip }}{% endautoescape %}
+
+ + \ No newline at end of file