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 }}
+
+
+
+ Produkt |
+ Beschreibung |
+ Anzahl |
+ Einzelpreis |
+ Gesamtpreis |
+
+
+ {% for item in object.invoiceitem_set.all %}
+
+ {{ item.name }} |
+ {{ item.description }} |
+ {{ item.count }} |
+ {{ object.currency }} {{ item.net_each }} |
+ {{ object.currency }} {{ item.net_total }} |
+
+ {% endfor %}
+
+
+ Zwischensumme |
+ |
+ |
+ |
+ {{ object.currency }} {{ object.price_net }} |
+
+
+
+ {{ object.tax_rate }}% USt |
+ |
+ |
+ |
+ {{ object.currency }} {{ object.tax }} |
+
+
+
+ Gesamtsumme |
+ |
+ |
+ |
+ {{ object.currency }} {{ object.price_gross }} |
+
+
+
+
+
+
+ Zahlungsmethode |
+ Zahlungs-ID |
+ Zahlungsdatum |
+ Betrag |
+
+
+ {% for payment in object.invoicepayment_set.all %}
+
+ {{ payment.gateway }} |
+ {{ payment.gateway_id }} |
+ {{ payment.timestamp }} |
+ {{ object.currency }} {{ payment.amount }} |
+
+ {% endfor %}
+
+ {% if not object.is_paid %}
+
+ Offener Betrag |
+ |
+ |
+ {{ object.currency }} {{ object.balance }} |
+
+ {% endif %}
+
+
+
+
+
+ Weitere Informationen |
+
+
+
+ {% autoescape off %}{{ bottom_tip }}{% endautoescape %} |
+
+
+
+
+
\ No newline at end of file