Refactor payment app

Generate invoice from HTML
Remove now unused dependency on PyInvoice
This commit is contained in:
Kumi 2021-06-10 13:18:52 +02:00
parent 0a4b55e2fe
commit d2792b8aa3
13 changed files with 484 additions and 242 deletions

View file

@ -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"

View file

@ -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

View file

@ -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<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 += "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

View file

@ -0,0 +1,4 @@
from .billingaddress import BillingAddress
from .invoice import Invoice
from .invoiceitem import InvoiceItem
from .invoicepayment import InvoicePayment

View file

@ -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
)

116
payment/models/invoice.py Normal file
View file

@ -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 }<br /><br />" if self.payment_instructions else "") + getValue("billing.bottom_tip", "")
bottom_tip += "<br />" 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

View file

@ -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

View file

@ -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

9
payment/pdfviews.py Normal file
View file

@ -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)

View file

@ -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

View file

@ -24,4 +24,3 @@ python-magic
bs4
django-starfield
pdfkit
git+https://kumig.it/kumisystems/PyInvoice

View file

@ -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;
}

View file

@ -0,0 +1,145 @@
{% load static %}
{% load dbsetting %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>{{ title }}</title>
<link rel="stylesheet" type="text/css" href="{% static "payment/invoice/invoice.css" %}">
</head>
<body>
<div class="invoice-box">
<table cellpadding="0" cellspacing="0">
<tr class="top">
<td colspan="2">
<table>
<tr>
<td class="title">
<img src="{% static "frontend/images/logo.png" %}" style="width: 100%; max-width: 300px" />
</td>
<td>
{{ type }} #: {{ object.id }}<br />
Datum: {{ object.created }}
</td>
</tr>
</table>
</td>
</tr>
<tr class="information">
<td colspan="2">
<table>
<tr>
<td>
<b>Empfänger:</b><br />
{% if object.billing_address.company %}{{ object.billing_address.company }}<br />{% endif %}
{{ object.billing_address.full_name }}<br />
{{ object.billing_address.street }}<br />
{{ object.billing_address.zip }} {{ object.billing_address.city }}<br />
{{ object.billing_address.country.name }}{% if object.billing_address.vat_id %}<br />
UID: {{ object.billing_address.vat_id }}{% endif %}
</td>
<td>
<b>Absender:</b>
{% dbsetting "billing.provider.name" "" %}<br />
{% dbsetting "billing.provider.street" "" %}<br />
{% dbsetting "billing.provider.zip" "" %} {% dbsetting "billing.provider.city" "" %}<br />
{% dbsetting "billing.provider.country" "" %}{% dbsetting "billing.provider.vat_id" "" as vat_id %}{% if vat_id %}<br />
UID: {{ vat_id }}{% endif %}
</td>
</tr>
</table>
</td>
</tr>
</table>
<h1>{{ title }}</h1>
<table cellpadding="0" cellspacing="0">
<tr class="heading">
<td>Produkt</td>
<td>Beschreibung</td>
<td>Anzahl</td>
<td>Einzelpreis</td>
<td>Gesamtpreis</td>
</tr>
{% for item in object.invoiceitem_set.all %}
<tr class="item{% if forloop.last %} last{% endif %}">
<td>{{ item.name }}</td>
<td>{{ item.description }}</td>
<td>{{ item.count }}</td>
<td>{{ object.currency }} {{ item.net_each }}</td>
<td>{{ object.currency }} {{ item.net_total }}</td>
</tr>
{% endfor %}
<tr class="tax">
<td>Zwischensumme</td>
<td></td>
<td></td>
<td></td>
<td>{{ object.currency }} {{ object.price_net }}</td>
</tr>
<tr class="tax last">
<td>{{ object.tax_rate }}% USt</td>
<td></td>
<td></td>
<td></td>
<td>{{ object.currency }} {{ object.tax }}</td>
</tr>
<tr class="total">
<td>Gesamtsumme</td>
<td></td>
<td></td>
<td></td>
<td>{{ object.currency }} {{ object.price_gross }}</td>
</tr>
</table>
<table cellpadding="0" cellspacing="0">
<tr class="heading">
<td>Zahlungsmethode</td>
<td>Zahlungs-ID</td>
<td>Zahlungsdatum</td>
<td>Betrag</td>
</tr>
{% for payment in object.invoicepayment_set.all %}
<tr class="details">
<td>{{ payment.gateway }}</td>
<td>{{ payment.gateway_id }}</td>
<td>{{ payment.timestamp }}</td>
<td>{{ object.currency }} {{ payment.amount }}</td>
</tr>
{% endfor %}
{% if not object.is_paid %}
<tr class="details balance">
<td>Offener Betrag</td>
<td></td>
<td></td>
<td>{{ object.currency }} {{ object.balance }}</td>
</tr>
{% endif %}
</table>
<table cellpadding="0" cellspacing="0">
<tr class="heading">
<td>Weitere Informationen</td>
</tr>
<tr class="details">
<td>{% autoescape off %}{{ bottom_tip }}{% endautoescape %}</td>
</tr>
</table>
</div>
</body>
</html>