This commit is contained in:
Felix Baumann
2026-06-28 10:20:36 +00:00
parent 64b32d0b44
commit 148efd919d
86 changed files with 2475 additions and 760 deletions
+2
View File
@@ -0,0 +1,2 @@
# invoices/__init__.py
default_app_config = "invoices.apps.InvoicesConfig"
+67 -1
View File
@@ -7,7 +7,9 @@ from .models import (
Invoice,
InvoiceItem,
Unit,
Reminder
Reminder,
InvoicePayment,
InvoicePaymentAllocation,
)
@admin.register(Unit)
@@ -273,3 +275,67 @@ class ReminderAdmin(admin.ModelAdmin):
)
ordering = ("-created_at",)
@admin.register(InvoicePayment)
class InvoicePaymentAdmin(admin.ModelAdmin):
list_display = (
"invoice",
"amount",
"date",
"payment_method",
"created_by",
"created_at",
)
list_filter = (
"payment_method",
"date",
"created_at",
)
search_fields = (
"invoice__invoice_number",
"invoice__customer__company_name",
"note",
)
readonly_fields = (
"created_at",
"created_by",
)
@admin.register(InvoicePaymentAllocation)
class InvoicePaymentAllocationAdmin(admin.ModelAdmin):
list_display = (
"payment",
"amount",
"reminder",
)
+20 -2
View File
@@ -1,5 +1,23 @@
from django.apps import AppConfig
class InvoicesConfig(AppConfig):
name = 'invoices'
name = "invoices"
def ready(self):
from django_apscheduler.jobstores import DjangoJobStore
from apscheduler.schedulers.background import BackgroundScheduler
from . import scheduler
sched = BackgroundScheduler()
sched.add_jobstore(DjangoJobStore(), "default")
sched.add_job(
scheduler.mark_overdue,
trigger="cron",
hour=1,
minute=0,
id="mark_overdue",
replace_existing=True,
)
sched.start()
+33 -1
View File
@@ -1,12 +1,41 @@
from django import forms
from core.forms.base import BaseForm
from .models import Customer, Item, Invoice, InvoiceItem, CustomerContact, CustomerLocation, Offer, OfferItem, Reminder
from .models import Customer, Item, Invoice, InvoiceItem, CustomerContact, CustomerLocation, Offer, OfferItem, Reminder, InvoicePayment
from projects.models import Project
from time_tracking.models import TimeEntry
from finance.models import Expense
from django.utils import timezone
from django.db.models import Q, Max
class InvoicePaymentForm(BaseForm):
class Meta:
model = InvoicePayment
fields = [
"amount",
"date",
"payment_method",
"note",
]
widgets = {
"date": forms.DateInput(
attrs={
"type": "date"
}
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["date"].initial = timezone.now().date()
self.apply_style()
class CustomerForm(BaseForm):
class Meta:
model = Customer
@@ -32,9 +61,12 @@ class ItemForm(BaseForm):
fields = [
"item_type",
"description",
"category",
"unit",
"price_net",
"tax_rate",
"price_buy",
"supplier",
]
def __init__(self, *args, **kwargs):
+32 -12
View File
@@ -1,5 +1,6 @@
# Generated by Django 6.0.6 on 2026-06-27 17:19
# Generated by Django 6.0.6 on 2026-06-28 10:19
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
@@ -40,10 +41,10 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('source_type', models.CharField(choices=[('item', 'Artikel'), ('time', 'Zeiterfassung'), ('expense', 'Ausgabe')], default='item', max_length=20, verbose_name='Quelle')),
('description', models.CharField(max_length=255, verbose_name='Beschreibung')),
('quantity', models.DecimalField(decimal_places=2, default=1, max_digits=10, verbose_name='Menge')),
('quantity', models.DecimalField(decimal_places=2, default=1, max_digits=10, validators=[django.core.validators.MinValueValidator(0, message='Die Anzahl darf nicht negativ sein.')], verbose_name='Menge')),
('unit', models.CharField(max_length=50, verbose_name='Einheit')),
('unit_price_net', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Einzelpreis Netto')),
('unit_tax_rate', models.DecimalField(decimal_places=2, default=19.0, max_digits=5, verbose_name='MwSt. Satz (%)')),
('unit_price_net', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0, message='Der Betrag darf nicht negativ sein.')], verbose_name='Einzelpreis Netto')),
('unit_tax_rate', models.DecimalField(decimal_places=2, default=19.0, max_digits=5, validators=[django.core.validators.MinValueValidator(0, message='Der Satz darf nicht negativ sein.')], verbose_name='MwSt. Satz (%)')),
('service_period_from', models.DateField(blank=True, null=True, verbose_name='Leistungszeitraum von')),
('service_period_to', models.DateField(blank=True, null=True, verbose_name='Leistungszeitraum bis')),
('created_at', models.DateTimeField(auto_now_add=True)),
@@ -53,14 +54,32 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Rechnungspositionen',
},
),
migrations.CreateModel(
name='InvoicePayment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0, message='Der Betrag darf nicht negativ sein.')], verbose_name='Betrag (€)')),
('date', models.DateTimeField(verbose_name='Datum')),
('note', models.TextField(blank=True, verbose_name='Notiz')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='InvoicePaymentAllocation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.DecimalField(decimal_places=2, max_digits=10)),
],
),
migrations.CreateModel(
name='Item',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('item_type', models.CharField(choices=[('service', 'Dienstleistung'), ('product', 'Ware')], default='product', max_length=20, verbose_name='Art')),
('description', models.CharField(max_length=255, verbose_name='Beschreibung')),
('price_net', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Preis Netto')),
('tax_rate', models.DecimalField(decimal_places=2, default=19.0, max_digits=5, verbose_name='MwSt. Satz (%)')),
('price_net', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0, message='Der Betrag darf nicht negativ sein.')], verbose_name='Preis Netto')),
('tax_rate', models.DecimalField(decimal_places=2, default=19.0, max_digits=5, validators=[django.core.validators.MinValueValidator(0, message='Der Satz darf nicht negativ sein.')], verbose_name='MwSt. Satz (%)')),
('price_buy', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(0, message='Der Betrag darf nicht negativ sein.')], verbose_name='Einkaufspreis')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
@@ -95,7 +114,7 @@ class Migration(migrations.Migration):
options={
'verbose_name': 'Angebot',
'verbose_name_plural': 'Angebote',
'ordering': ['-issue_date'],
'ordering': ['-offer_number'],
},
),
migrations.CreateModel(
@@ -103,10 +122,10 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('description', models.CharField(max_length=255, verbose_name='Beschreibung')),
('quantity', models.DecimalField(decimal_places=2, default=1, max_digits=10, verbose_name='Menge')),
('quantity', models.DecimalField(decimal_places=2, default=1, max_digits=10, validators=[django.core.validators.MinValueValidator(0, message='Die Anzahl darf nicht negativ sein.')], verbose_name='Menge')),
('unit', models.CharField(max_length=50, verbose_name='Einheit')),
('unit_price_net', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Einzelpreis Netto')),
('unit_tax_rate', models.DecimalField(decimal_places=2, default=19.0, max_digits=5, verbose_name='MwSt. Satz (%)')),
('unit_price_net', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0, message='Der Betrag darf nicht negativ sein.')], verbose_name='Einzelpreis Netto')),
('unit_tax_rate', models.DecimalField(decimal_places=2, default=19.0, max_digits=5, validators=[django.core.validators.MinValueValidator(0, message='Der Satz darf nicht negativ sein.')], verbose_name='MwSt. Satz (%)')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
@@ -123,7 +142,7 @@ class Migration(migrations.Migration):
('level', models.PositiveSmallIntegerField(default=1, help_text='1 = Erste Mahnung, 2 = Zweite Mahnung, 3 = Letzte Mahnung', verbose_name='Mahnstufe')),
('issue_date', models.DateField(verbose_name='Mahndatum')),
('due_date', models.DateField(blank=True, null=True, verbose_name='Neue Zahlungsfrist')),
('fee', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Mahngebühr')),
('fee', models.DecimalField(decimal_places=2, default=0, max_digits=10, validators=[django.core.validators.MinValueValidator(0, message='Der Betrag darf nicht negativ sein.')], verbose_name='Mahngebühr')),
('note', models.TextField(blank=True, verbose_name='Hinweis')),
('pdf_file', models.FileField(blank=True, null=True, upload_to='reminder_pdfs/', verbose_name='PDF')),
('pdf_generated_at', models.DateTimeField(blank=True, null=True, verbose_name='PDF erstellt am')),
@@ -210,6 +229,7 @@ class Migration(migrations.Migration):
('due_date', models.DateField(blank=True, null=True, verbose_name='Fälligkeitsdatum')),
('note', models.TextField(blank=True, verbose_name='Notiz')),
('created_at', models.DateTimeField(auto_now_add=True)),
('is_small_business', models.BooleanField(default=False, verbose_name='Kleinunternehmer (§19 UStG)')),
('pdf_file', models.FileField(blank=True, null=True, upload_to='invoice_pdfs/', verbose_name='PDF')),
('pdf_generated_at', models.DateTimeField(blank=True, null=True, verbose_name='PDF erstellt am')),
('cancel_number', models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='Stornorechnungsnummer')),
@@ -230,7 +250,7 @@ class Migration(migrations.Migration):
options={
'verbose_name': 'Rechnung',
'verbose_name_plural': 'Rechnungen',
'ordering': ['-issue_date'],
'ordering': ['-invoice_number'],
},
),
]
+38 -1
View File
@@ -1,6 +1,7 @@
# Generated by Django 6.0.6 on 2026-06-27 17:19
# Generated by Django 6.0.6 on 2026-06-28 10:19
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
@@ -13,6 +14,7 @@ class Migration(migrations.Migration):
('invoices', '0001_initial'),
('projects', '0001_initial'),
('time_tracking', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
@@ -36,6 +38,36 @@ class Migration(migrations.Migration):
name='time_entry',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='time_tracking.timeentry', verbose_name='Zeiterfassung'),
),
migrations.AddField(
model_name='invoicepayment',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='invoicepayment',
name='invoice',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='invoices.invoice'),
),
migrations.AddField(
model_name='invoicepayment',
name='payment_method',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.paymentmethod', verbose_name='Zahlungsmethode'),
),
migrations.AddField(
model_name='invoicepaymentallocation',
name='payment',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='invoices.invoicepayment'),
),
migrations.AddField(
model_name='item',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.category', verbose_name='Kategorie'),
),
migrations.AddField(
model_name='item',
name='supplier',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.supplier', verbose_name='Lieferant'),
),
migrations.AddField(
model_name='invoiceitem',
name='item',
@@ -61,6 +93,11 @@ class Migration(migrations.Migration):
name='invoice',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='reminders', to='invoices.invoice', verbose_name='Rechnung'),
),
migrations.AddField(
model_name='invoicepaymentallocation',
name='reminder',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='payment_allocations', to='invoices.reminder'),
),
migrations.AddField(
model_name='item',
name='unit',
+261 -14
View File
@@ -1,6 +1,12 @@
from django.db import models
from django.core.exceptions import ValidationError
from core.models import CompanySetting
from django.db.models import Sum, F, DecimalField, ExpressionWrapper
from django.contrib.auth import get_user_model
from django.utils import timezone
from decimal import Decimal
from django.core.validators import MinValueValidator
User = get_user_model()
class Unit(models.Model):
name = models.CharField(
@@ -92,15 +98,21 @@ class Item(models.Model):
item_type = models.CharField(max_length=20, choices=ItemType.choices, default=ItemType.PRODUCT, verbose_name="Art")
description = models.CharField(max_length=255, verbose_name="Beschreibung")
category = models.ForeignKey("finance.Category", on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Kategorie")
supplier = models.ForeignKey("finance.Supplier", on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Lieferant")
unit = models.ForeignKey(Unit,on_delete=models.PROTECT,related_name="item_items",verbose_name="Einheit")
price_net = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Preis Netto")
price_net = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Preis Netto",validators=[MinValueValidator(0,message="Der Betrag darf nicht negativ sein.")])
tax_rate = models.DecimalField(
max_digits=5,
decimal_places=2,
default=19.00,
verbose_name="MwSt. Satz (%)"
verbose_name="MwSt. Satz (%)",
validators=[MinValueValidator(0,message="Der Satz darf nicht negativ sein.")]
)
price_buy = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Einkaufspreis", null=True, blank=True,validators=[MinValueValidator(0,message="Der Betrag darf nicht negativ sein.")])
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
@@ -142,6 +154,8 @@ class Invoice(models.Model):
note = models.TextField(blank=True, verbose_name="Notiz")
created_at = models.DateTimeField(auto_now_add=True)
is_small_business = models.BooleanField(default=False,verbose_name="Kleinunternehmer (§19 UStG)")
pdf_file = models.FileField(upload_to="invoice_pdfs/",null=True,blank=True,verbose_name="PDF")
pdf_generated_at = models.DateTimeField(null=True,blank=True,verbose_name="PDF erstellt am")
@@ -163,7 +177,7 @@ class Invoice(models.Model):
class Meta:
verbose_name = "Rechnung"
verbose_name_plural = "Rechnungen"
ordering = ["-issue_date"]
ordering = ["-invoice_number"]
def save(self, *args, **kwargs):
@@ -201,9 +215,106 @@ class Invoice(models.Model):
super().save(*args, **kwargs)
def clean(self):
super().clean()
if self.issue_date and self.due_date:
if self.issue_date > self.due_date:
raise ValidationError(
{
"due_date": "Das Fälligkeitsdatum muss nach dem Rechnungsdatum liegen."
}
)
def __str__(self):
return self.invoice_number
@property
def total_net(self):
result = self.items.aggregate(
total=Sum(
ExpressionWrapper(
F("quantity") * F("unit_price_net"),
output_field=DecimalField()
)
)
)
return result["total"] or Decimal("0.00")
@property
def total_tax(self):
total = Decimal("0.00")
for item in self.items.all():
item_net = item.calculated_total_net
total += (
item_net *
item.unit_tax_rate /
Decimal("100")
)
return total
@property
def total_amount(self):
if self.is_small_business:
return self.total_net
return self.total_net + self.total_tax
@property
def paid_amount(self):
return self.payments.aggregate(
total=Sum("amount")
)["total"] or Decimal("0.00")
@property
def paid_reminder_amount(self):
return self.reminders.aggregate(
total=Sum(
"payment_allocations__amount"
)
)["total"] or Decimal("0.00")
@property
def paid_invoice_amount(self):
return (
self.paid_amount
- self.paid_reminder_amount
)
@property
def reminder_amount(self):
return self.reminders.filter(
status__in=[
Reminder.Status.SENT,
Reminder.Status.EXPIRED,
Reminder.Status.PAID,
]
).aggregate(
total=Sum("fee")
)["total"] or Decimal("0.00")
@property
def total_with_reminders(self):
return (
self.total_amount
+ self.reminder_amount
)
@property
def open_amount(self):
return (
self.total_with_reminders
- self.paid_amount
)
class InvoiceItem(models.Model):
invoice = models.ForeignKey(Invoice,on_delete=models.CASCADE,related_name="items",verbose_name="Rechnung")
@@ -212,10 +323,10 @@ class InvoiceItem(models.Model):
time_entry = models.ForeignKey("time_tracking.TimeEntry",on_delete=models.SET_NULL,null=True,blank=True,verbose_name="Zeiterfassung") # Quelle 2
expense = models.ForeignKey("finance.Expense",on_delete=models.SET_NULL,null=True,blank=True,verbose_name="Ausgabe") # Quelle 3
description = models.CharField(max_length=255,verbose_name="Beschreibung")
quantity = models.DecimalField(max_digits=10,decimal_places=2,default=1,verbose_name="Menge")
quantity = models.DecimalField(max_digits=10,decimal_places=2,default=1,verbose_name="Menge",validators=[MinValueValidator(0,message="Die Anzahl darf nicht negativ sein.")])
unit = models.CharField(max_length=50,verbose_name="Einheit")
unit_price_net = models.DecimalField(max_digits=10,decimal_places=2,verbose_name="Einzelpreis Netto")
unit_tax_rate = models.DecimalField(max_digits=5,decimal_places=2,default=19.00,verbose_name="MwSt. Satz (%)")
unit_price_net = models.DecimalField(max_digits=10,decimal_places=2,verbose_name="Einzelpreis Netto",validators=[MinValueValidator(0,message="Der Betrag darf nicht negativ sein.")])
unit_tax_rate = models.DecimalField(max_digits=5,decimal_places=2,default=19.00,verbose_name="MwSt. Satz (%)",validators=[MinValueValidator(0,message="Der Satz darf nicht negativ sein.")])
service_period_from = models.DateField(null=True,blank=True,verbose_name="Leistungszeitraum von",)
service_period_to = models.DateField(null=True,blank=True,verbose_name="Leistungszeitraum bis",)
created_at = models.DateTimeField(auto_now_add=True)
@@ -229,7 +340,6 @@ class InvoiceItem(models.Model):
verbose_name_plural = "Rechnungspositionen"
def clean(self):
sources = [
self.item,
self.time_entry,
@@ -239,9 +349,13 @@ class InvoiceItem(models.Model):
selected = sum(1 for s in sources if s)
if selected != 1:
raise ValidationError(
"Genau eine Quelle muss ausgewählt werden."
)
raise ValidationError("Genau eine Quelle muss ausgewählt werden.")
if self.service_period_from and self.service_period_to:
if self.service_period_from >= self.service_period_to:
raise ValidationError({
'service_period_to': 'Das Ende des Leistungszeitraums muss nach dem Beginn liegen.'
})
def __str__(self):
return self.description
@@ -272,7 +386,6 @@ class Reminder(models.Model):
DRAFT = "draft", "Entwurf"
SENT = "sent", "Versendet"
PAID = "paid", "Bezahlt"
#PARTIALLY_PAID = "partial", "Teilweise bezahlt"
CANCELED = "canceled", "Storniert"
EXPIRED = "expired", "Frist abgelaufen"
@@ -288,7 +401,7 @@ class Reminder(models.Model):
level = models.PositiveSmallIntegerField(default=1,verbose_name="Mahnstufe",help_text="1 = Erste Mahnung, 2 = Zweite Mahnung, 3 = Letzte Mahnung",)
issue_date = models.DateField(verbose_name="Mahndatum",)
due_date = models.DateField(null=True,blank=True,verbose_name="Neue Zahlungsfrist",)
fee = models.DecimalField(max_digits=10,decimal_places=2,default=0,verbose_name="Mahngebühr",)
fee = models.DecimalField(max_digits=10,decimal_places=2,default=0,verbose_name="Mahngebühr",validators=[MinValueValidator(0,message="Der Betrag darf nicht negativ sein.")])
note = models.TextField(blank=True,verbose_name="Hinweis",)
pdf_file = models.FileField(upload_to="reminder_pdfs/",null=True,blank=True,verbose_name="PDF",)
pdf_generated_at = models.DateTimeField(null=True,blank=True,verbose_name="PDF erstellt am",)
@@ -302,6 +415,27 @@ class Reminder(models.Model):
def __str__(self):
return self.reminder_number
def clean(self):
super().clean()
if self.issue_date and self.due_date:
if self.issue_date > self.due_date:
raise ValidationError(
{
"due_date": "Das Fälligkeitsdatum muss nach dem Mahnungsdatum liegen."
}
)
@property
def paid_amount(self):
return self.payment_allocations.aggregate(
total=Sum("amount")
)["total"] or Decimal("0.00")
@property
def is_paid(self):
return self.paid_amount >= self.fee
@@ -451,10 +585,9 @@ class Offer(models.Model):
class Meta:
verbose_name = "Angebot"
verbose_name_plural = "Angebote"
ordering = ["-issue_date"]
ordering = ["-offer_number"]
def save(self, *args, **kwargs):
if self.pk:
old = Offer.objects.get(pk=self.pk)
@@ -485,6 +618,17 @@ class Offer(models.Model):
super().save(*args, **kwargs)
def clean(self):
super().clean()
if self.issue_date and self.due_date:
if self.issue_date > self.due_date:
raise ValidationError(
{
"due_date": "Das Gültigkeitsdatum muss nach dem Angebotsdatum liegen."
}
)
def __str__(self):
return self.offer_number
@@ -515,6 +659,7 @@ class OfferItem(models.Model):
decimal_places=2,
default=1,
verbose_name="Menge",
validators=[MinValueValidator(0,message="Die Anzahl darf nicht negativ sein.")]
)
unit = models.CharField(
@@ -526,6 +671,7 @@ class OfferItem(models.Model):
max_digits=10,
decimal_places=2,
verbose_name="Einzelpreis Netto",
validators=[MinValueValidator(0,message="Der Betrag darf nicht negativ sein.")]
)
unit_tax_rate = models.DecimalField(
@@ -533,6 +679,7 @@ class OfferItem(models.Model):
decimal_places=2,
default=19.00,
verbose_name="MwSt. Satz (%)",
validators=[MinValueValidator(0,message="Der Satz darf nicht negativ sein.")]
)
created_at = models.DateTimeField(
@@ -593,3 +740,103 @@ class DocumentNumberSettings(models.Model):
class Meta:
verbose_name = "Dokumentnummer"
verbose_name_plural = "Dokumentnummern"
class InvoicePayment(models.Model):
invoice = models.ForeignKey(
Invoice,
on_delete=models.CASCADE,
related_name="payments"
)
amount = models.DecimalField(
max_digits=10,
decimal_places=2,
verbose_name="Betrag (€)",
validators=[MinValueValidator(0,message="Der Betrag darf nicht negativ sein.")]
)
date = models.DateTimeField(verbose_name="Datum")
payment_method = models.ForeignKey(
"finance.PaymentMethod",
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name="Zahlungsmethode",
)
note = models.TextField(blank=True, verbose_name="Notiz")
created_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
)
created_at = models.DateTimeField(
auto_now_add=True
)
def clean(self):
if self.date and self.invoice_id:
if self.date.date() < self.invoice.issue_date:
raise ValidationError({
'date': 'Das Zahlungsdatum darf nicht vor dem Rechnungsdatum liegen.'
})
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
@property
def allocated_amount(self):
return self.allocations.aggregate(
total=Sum("amount")
)["total"] or 0
@property
def remaining_amount(self):
return self.amount - self.allocated_amount
class InvoicePaymentAllocation(models.Model):
payment = models.ForeignKey(
InvoicePayment,
on_delete=models.CASCADE,
related_name="allocations"
)
reminder = models.ForeignKey(
Reminder,
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="payment_allocations"
)
amount = models.DecimalField(
max_digits=10,
decimal_places=2
)
+20
View File
@@ -0,0 +1,20 @@
from django.utils import timezone
from .models import Invoice, Offer, Reminder
def mark_overdue():
today = timezone.now().date()
Invoice.objects.filter(
status="sent",
due_date__lt=today,
).update(status="overdue")
Offer.objects.filter(
status="sent",
due_date__lt=today,
).update(status="expired")
Reminder.objects.filter(
status="sent",
due_date__lt=today,
).update(status="expired")
+47 -38
View File
@@ -1,8 +1,42 @@
import django_tables2 as tables
from .models import Item, Customer, Reminder, Invoice, InvoiceItem, Offer, OfferItem
from .models import Item, Customer, Reminder, Invoice, InvoiceItem, Offer, OfferItem, InvoicePayment
class InvoicePaymentTable(tables.Table):
invoice = tables.Column(
verbose_name="Rechnung"
)
amount = tables.Column(
verbose_name="Betrag"
)
date = tables.DateColumn(
format="d.m.Y",
verbose_name="Datum"
)
payment_method = tables.Column(
verbose_name="Zahlungsmethode"
)
created_by = tables.Column(
verbose_name="Erfasst von"
)
class Meta:
model = InvoicePayment
fields = [
"invoice",
"amount",
"date",
"payment_method",
"created_by",
]
attrs = {
"class": "w-full text-sm"
}
class OfferItemTable(tables.Table):
unit_price_net = tables.TemplateColumn(
template_name="invoices/tables/offer_items/unit_price.html",
verbose_name="Einzelpreis",
@@ -246,55 +280,30 @@ class ReminderTable(tables.Table):
}
class ItemTable(tables.Table):
item_type = tables.TemplateColumn(
template_name="invoices/tables/items/item_type.html",
verbose_name="Typ",
orderable=False
)
toggle_prefix = "item"
description = tables.TemplateColumn(
template_name="invoices/tables/items/description.html",
verbose_name="Beschreibung",
orderable=False
detail_template = (
"invoices/tables/items/detail.html"
)
unit = tables.TemplateColumn(
template_name="invoices/tables/items/unit.html",
verbose_name="Einheit",
orderable=False
)
price_net = tables.TemplateColumn(
template_name="invoices/tables/items/price_net.html",
verbose_name="Preis Netto",
orderable=False
)
tax_rate = tables.TemplateColumn(
template_name="invoices/tables/items/tax_rate.html",
verbose_name="MwSt. Satz (%)",
orderable=False
)
actions = tables.TemplateColumn(
template_name="invoices/tables/items/actions.html",
verbose_name="Aktion",
orderable=False
)
class Meta:
model = Item
template_name = (
"django_tables2/table_toggle.html"
)
fields = (
"item_type",
"description",
"category",
"unit",
"price_net",
"tax_rate",
"actions",
)
attrs = {
"class": "w-full text-sm min-w-[700px]"
"class": "w-full text-sm min-w-[900px]",
}
@@ -18,7 +18,7 @@ Kunde bearbeiten
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<h2 class="text-xl font-bold mb-2">Kundendaten</h2>
<h2 class="text-lg font-bold mb-2">Kundendaten</h2>
<div class="grid md:grid-cols-2 gap-4 mb-8">
@@ -18,7 +18,7 @@ Kundenstammdaten verwalten
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">
Kunden anlegen
</p>
@@ -36,7 +36,7 @@ Kundenstammdaten verwalten
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors mb-8">
<div class="mb-6">
<h2 class="text-xl font-bold">Kundenliste</h2>
<h2 class="text-lg font-bold">Kundenliste</h2>
<p class="text-slate-400 text-sm mt-1">Alle Kunden, inkl. Standorte und Ansprechpartner</p>
</div>
@@ -49,7 +49,7 @@ Kundenstammdaten verwalten
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="mb-6">
<h2 class="text-xl font-bold">Archivierte Kunden</h2>
<h2 class="text-lg font-bold">Archivierte Kunden</h2>
<p class="text-slate-400 text-sm mt-1">Alle inaktiven Kunden, inkl. Standorte und Ansprechpartner</p>
</div>
@@ -0,0 +1,249 @@
{% extends "base.html" %}
{% block title %}Rechnungswesen - Rechnungen{% endblock %}
{% block header_title %}
Zahlung erfassen
{% endblock %}
{% block header_description %}
Zahlung für Rechnung {{ invoice.invoice_number }} hinzufügen
{% endblock %}
{% block content %}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- PAYMENT FORM -->
<div class="lg:col-span-2">
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<form method="post" enctype="multipart/form-data" class="space-y-4">
{% csrf_token %}
{{ form.as_p }}
<button class="btn btn-save">
Speichern
</button>
</form>
</div>
</div>
<!-- PAYMENT SUMMARY -->
<div class="lg:col-span-1">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow p-6 font-mono text-sm">
<!-- HEADER -->
<div class="text-center">
<h2 class="text-lg font-bold">
Zahlungsübersicht
</h2>
<p class="text-xs text-slate-400 mt-1">
Rechnung {{ invoice.invoice_number }}
</p>
</div>
<div class="border-t border-dashed border-slate-300 dark:border-slate-600 my-5"></div>
<!-- RECHNUNG -->
<div class="space-y-2">
{% if not invoice.is_small_business %}
<div class="flex justify-between">
<span class="text-slate-400">
Netto
</span>
<span>
{{ invoice.total_net|floatformat:2 }} €
</span>
</div>
<div class="flex justify-between">
<span class="text-slate-400">
Umsatzsteuer
</span>
<span>
{{ invoice.total_tax|floatformat:2 }} €
</span>
</div>
{% endif %}
<div class="flex justify-between font-semibold">
<span>
Rechnungsbetrag
</span>
<span>
{{ invoice.total_amount|floatformat:2 }} €
</span>
</div>
</div>
<!-- MAHNUNGEN -->
{% if invoice.reminder_amount > 0 %}
<div class="border-t border-dashed border-slate-300 dark:border-slate-600 my-5"></div>
<div class="space-y-2">
<div class="flex justify-between text-red-600">
<span>
Mahngebühren
</span>
<span>
+ {{ invoice.reminder_amount|floatformat:2 }} €
</span>
</div>
{% for reminder in invoice.reminders.all %}
{% if reminder.status != "canceled" %}
<div class="flex justify-between text-xs text-slate-400">
<span>
{{ reminder.level }}. Mahnung
</span>
<span>
{{ reminder.fee|floatformat:2 }} €
</span>
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
<!-- GESAMT -->
<div class="border-t border-dashed border-slate-300 dark:border-slate-600 my-5"></div>
<div class="flex justify-between text-lg font-bold">
<span>
Gesamtforderung
</span>
<span>
{{ invoice.total_with_reminders|floatformat:2 }} €
</span>
</div>
<!-- ZAHLUNGEN -->
<div class="mt-4 space-y-2">
<div class="flex justify-between text-green-600">
<span>
Bereits bezahlt
</span>
<span>
- {{ invoice.paid_amount|floatformat:2 }} €
</span>
</div>
{% for payment in invoice.payments.all %}
<div class="flex justify-between text-xs text-slate-400">
<span>
Zahlung {{ payment.date|date:"d.m.Y" }}
</span>
<span>
{{ payment.amount|floatformat:2 }} €
</span>
</div>
{% endfor %}
</div>
<!-- OFFEN -->
<div class="border-t border-dashed border-slate-300 dark:border-slate-600 my-5"></div>
<div class="bg-slate-100 dark:bg-slate-700 rounded-xl p-4 text-center">
<p class="text-xs uppercase text-slate-400">
Offener Betrag
</p>
<p class="text-3xl font-bold text-red-600 mt-1">
{{ invoice.open_amount|floatformat:2 }} €
</p>
</div>
<!-- FOOTER -->
<div class="border-t border-dashed border-slate-300 dark:border-slate-600 my-5"></div>
<div class="text-center text-xs text-slate-400">
Zahlungserfassung
</div>
</div>
</div>
</div>
{% endblock %}
@@ -16,7 +16,7 @@ Details & Positionen
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">
Positionen zur Rechnung hinzufügen
</p>
@@ -32,7 +32,6 @@ Details & Positionen
<a href="{% url 'invoices:invoices_download' invoice.id %}" class="btn btn-action">Download</a>
<a href="{% url 'invoices:invoices_cancel' invoice.id %}" onclick="startDownloadAndRedirect()" class="btn btn-delete">Stornieren</a>
<a href="{% url 'invoices:invoices_paid' invoice.id %}" class="btn btn-action">Zahlung verbuchen</a>
<a href="{% url 'invoices:invoices_overdue' invoice.id %}" class="btn btn-action">Überfällig</a>
{% endif %}
{% if invoice.status == "paid" %}
<a href="{% url 'invoices:invoices_download' invoice.id %}" class="btn btn-action">Download</a>
@@ -44,6 +43,8 @@ Details & Positionen
{% endif %}
{% if invoice.status == "overdue" %}
<a href="{% url 'invoices:invoices_download' invoice.id %}" class="btn btn-action">Download</a>
<a href="{% url 'invoices:invoices_cancel' invoice.id %}" onclick="startDownloadAndRedirect()" class="btn btn-delete">Stornieren</a>
<a href="{% url 'invoices:invoices_paid' invoice.id %}" class="btn btn-action">Zahlung verbuchen</a>
{% endif %}
</div>
@@ -11,7 +11,7 @@
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">Neue Rechnung erstellen</p>
</div>
@@ -24,7 +24,7 @@
</div>
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="mb-6">
<h2 class="text-xl font-bold">Rechnungen</h2>
<h2 class="text-lg font-bold">Rechnungen</h2>
<p class="text-slate-400 text-sm mt-1">Alle Rechnungen mit aktuellem Status</p>
</div>
<div class="overflow-x-auto rounded-xl">
@@ -158,7 +158,7 @@ table {
margin-top:30px;
margin-left:auto;
width:250px;
width: 300px;
}
@@ -178,7 +178,7 @@ table {
border-top:1px solid #cbd5e1;
margin-top:10px;
padding-top:10px;
font-size:16px;
font-size:14px;
font-weight:bold;
}
@@ -454,7 +454,7 @@ Preis
</th>
{% if not company.is_small_business %}
{% if not invoice.is_small_business %}
<th class="left">
Steuer
@@ -518,7 +518,7 @@ Gesamt
</td>
{% if not company.is_small_business %}
{% if not invoice.is_small_business %}
<td class="left">
{{ item.unit_tax_rate|floatformat:0 }} %
@@ -569,7 +569,7 @@ Netto
{% if not company.is_small_business %}
{% if not invoice.is_small_business %}
{% for tax_rate,tax_amount in tax_summary.items %}
<div class="total-row">
@@ -603,7 +603,7 @@ Gesamt
<span>
{% if company.is_small_business %}
{% if invoice.is_small_business %}
{{ total_net|floatformat:2 }}
{% else %}
{{ total_gross|floatformat:2 }}
@@ -644,7 +644,7 @@ Rechnungshinweis
{% if company.is_small_business %}
{% if invoice.is_small_business %}
<div class="note">
@@ -155,10 +155,9 @@ table {
.total-box {
margin-top:30px;
margin-left:auto;
width:250px;
width:300px;
}
@@ -174,11 +173,10 @@ table {
.total-final {
border-top:1px solid #cbd5e1;
margin-top:10px;
padding-top:10px;
font-size:16px;
font-size:14px;
font-weight:bold;
}
@@ -414,12 +412,11 @@ Stornodatum: {{ cancel_date|date:"d.m.Y" }}
<!-- POSITIONEN -->
<div class="items-wrapper">
<table class="items">
<table class="items">
<thead>
@@ -429,37 +426,46 @@ Stornodatum: {{ cancel_date|date:"d.m.Y" }}
Nr.
</th>
<th>
Beschreibung
</th>
{% if show_service_date %}
<th class="left">Leistungsdatum</th>
{% endif %}
<th class="left">
{% if show_service_date %}
<th>
Leistungsdatum
</th>
{% endif %}
<th class="right">
Menge
</th>
<th class="left">
<th>
Einheit
</th>
<th class="left">
Preis
<th class="right">
Einzelpreis
</th>
{% if not company.is_small_business %}
{% if not invoice.is_small_business %}
<th class="left">
<th class="right">
Steuer
</th>
{% endif %}
<th class="left">
<th class="right">
Gesamt
</th>
@@ -469,7 +475,6 @@ Gesamt
</thead>
<tbody>
@@ -483,40 +488,52 @@ Gesamt
</td>
<td>
{{ item.description }}
Storno: {{ item.description }}
</td>
{% if show_service_date %}
<td class="left">
{% if item.service_period_from %}
{{ item.service_period_from|date:"d.m.Y" }}
{% endif %}
{% if item.service_period_to %}
- {{ item.service_period_to|date:"d.m.Y" }}
{% endif %}
</td>
{% endif %}
<td class="left">
{% if show_service_date %}
<td>
{% if item.service_period_from %}
{{ item.service_period_from|date:"d.m.Y" }}
{% endif %}
{% if item.service_period_to %}
- {{ item.service_period_to|date:"d.m.Y" }}
{% endif %}
</td>
{% endif %}
<td class="right">
{{ item.quantity }}
</td>
<td class="left">
<td>
{{ item.unit }}
</td>
<td class="left">
<td class="right">
-{{ item.unit_price_net|floatformat:2 }} €
</td>
{% if not company.is_small_business %}
<td class="left">
{% if not invoice.is_small_business %}
<td class="right">
{{ item.unit_tax_rate|floatformat:0 }} %
</td>
@@ -524,7 +541,7 @@ Gesamt
<td class="left">
<td class="right">
-{{ item.calculated_total_net|floatformat:2 }} €
</td>
@@ -540,14 +557,28 @@ Gesamt
</table>
</div>
<!-- SUMMEN -->
<!-- SUMMEN -->
<div class="total-box">
@@ -565,7 +596,9 @@ Netto
{% if not company.is_small_business %}
{% if not invoice.is_small_business %}
{% for tax_rate,tax_amount in tax_summary.items %}
<div class="total-row">
@@ -574,32 +607,27 @@ Netto
Umsatzsteuer {{ tax_rate|floatformat:0 }} %
</span>
<span>
-{{ tax_amount|floatformat:2 }} €
</span>
</div>
{% endfor %}
{% endif %}
<div class="total-row total-final">
<span>
Gesamt
Rechnungsbetrag
</span>
<span>
{% if company.is_small_business %}
{% if invoice.is_small_business %}
-{{ total_net|floatformat:2 }}
{% else %}
-{{ total_gross|floatformat:2 }}
@@ -609,12 +637,41 @@ Gesamt
</span>
</div>
<div class="total-row">
<span>
Bereits bezahlt
</span>
<span>
-{{ paid_amount|floatformat:2 }} €
</span>
</div>
<div class="total-row total-final">
<span>
Erstattungsbetrag
</span>
<span>
{{ refund_amount|floatformat:2 }} €
</span>
</div>
</div>
<div class="note">
<div class="receiver-title">
@@ -623,37 +680,45 @@ Gesamt
<br>
Die Rechnung {{ invoice.invoice_number }}
wird durch diese Stornorechnung vollständig aufgehoben.
{% if original_status == "paid" %}
Die Rechnung
<strong>{{ invoice.invoice_number }}</strong>
vom {{ invoice.issue_date|date:"d.m.Y" }}
wird durch diese Stornorechnung aufgehoben.
<br><br>
Die ursprüngliche Rechnung wurde bereits bezahlt.
{% if paid_amount > 0 %}
Es entsteht ein Guthaben in Höhe von
<br><br>
{% if company.is_small_business %}
{{ total_net|floatformat:2 }}
{% else %}
{{ total_gross|floatformat:2 }}
{% endif %}
€.
Die ursprüngliche Rechnung wurde bereits bezahlt.
Der zu erstattende Betrag beträgt:
<strong>
{{ refund_amount|floatformat:2 }} €
</strong>
<br><br>
Die Rückerstattung erfolgt separat.
Die Rückerstattung erfolgt separat.
{% endif %}
</div>
{% if company.is_small_business %}
{% if invoice.is_small_business %}
<div class="note">
@@ -18,7 +18,7 @@ Leistungen und Artikel verwalten
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">
Leistungen und Artikel anlegen
</p>
@@ -38,7 +38,7 @@ Leistungen und Artikel verwalten
<div class="flex flex-wrap justify-between items-center mb-6">
<div>
<h2 class="text-xl font-bold">
<h2 class="text-lg font-bold">
Artikel und Leistungen
</h2>
@@ -16,7 +16,7 @@ Details & Positionen
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">
Positionen zum Angebot hinzufügen
</p>
@@ -32,7 +32,6 @@ Details & Positionen
<a href="{% url 'invoices:offers_download' offer.id %}" class="btn btn-action">Download</a>
<a href="{% url 'invoices:offers_accept' offer.id %}" class="btn btn-action">Akzeptiert</a>
<a href="{% url 'invoices:offers_reject' offer.id %}" class="btn btn-action">Abgelehnt</a>
<a href="{% url 'invoices:offers_expire' offer.id %}" class="btn btn-action">Ausgelaufen</a>
{% endif %}
{% if offer.status == "accepted" or offer.status == "rejected" or offer.status == "expired" %}
<a href="{% url 'invoices:offers_download' offer.id %}" class="btn btn-action">Download</a>
@@ -11,7 +11,7 @@
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">Neues Angebot erstellen</p>
</div>
@@ -24,7 +24,7 @@
</div>
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="mb-6">
<h2 class="text-xl font-bold">Angebote</h2>
<h2 class="text-lg font-bold">Angebote</h2>
<p class="text-slate-400 text-sm mt-1">Alle Angebote mit aktuellem Status</p>
</div>
<div class="overflow-x-auto rounded-xl">
+2 -2
View File
@@ -158,7 +158,7 @@ table {
margin-top:30px;
margin-left:auto;
width:250px;
width: 300px;
}
@@ -178,7 +178,7 @@ table {
border-top:1px solid #cbd5e1;
margin-top:10px;
padding-top:10px;
font-size:16px;
font-size:14px;
font-weight:bold;
}
+15 -22
View File
@@ -18,44 +18,37 @@ Angebote, Rechnungen, Mahnungen verwalten
<!-- MODULE GRID -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<a href="{% url 'invoices:invoices_offers_overview' %}" class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<a href="{% url 'invoices:invoices_offers_overview' %}" class="border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="text-2xl mb-2">📄</div>
<h2 class="text-xl font-bold">Angebote</h2>
<h2 class="text-lg font-bold">Angebote</h2>
<p class="text-slate-400 text-sm mt-1">Angebote erstellen und verwalten</p>
</a>
<a href="{% url 'invoices:invoices_invoices_overview' %}" class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<a href="{% url 'invoices:invoices_invoices_overview' %}" class="border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="text-2xl mb-2">🧾</div>
<h2 class="text-xl font-bold">Rechnungen</h2>
<h2 class="text-lg font-bold">Rechnungen</h2>
<p class="text-slate-400 text-sm mt-1">Rechnungen erstellen und verwalten</p>
</a>
<a href="{% url 'invoices:invoices_reminders_overview' %}" class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<a href="{% url 'invoices:invoices_reminders_overview' %}" class="border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="text-2xl mb-2">⚠️</div>
<h2 class="text-xl font-bold">Mahnungen</h2>
<h2 class="text-lg font-bold">Mahnungen</h2>
<p class="text-slate-400 text-sm mt-1">Offene Forderungen verfolgen</p>
</a>
<a href="{% url 'invoices:customers_overview' %}" class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<a href="{% url 'invoices:customers_overview' %}" class="border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="text-2xl mb-2">👥</div>
<h2 class="text-xl font-bold">Kunden</h2>
<h2 class="text-lg font-bold">Kunden</h2>
<p class="text-slate-400 text-sm mt-1">Kundenstammdaten verwalten</p>
</a>
<a href="{% url 'invoices:items_overview' %}" class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<a href="{% url 'invoices:items_overview' %}" class="border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="text-2xl mb-2">📦</div>
<h2 class="text-xl font-bold">Posten</h2>
<h2 class="text-lg font-bold">Posten</h2>
<p class="text-slate-400 text-sm mt-1">Leistungen und Artikel verwalten</p>
</a>
</div>
<!-- OPTIONAL INFO BOX -->
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors mt-8">
<h2 class="text-xl font-bold">
Zusammenfassung
</h2>
<p class="text-slate-400 text-sm mt-1">
folgt
</p>
<a href="{% url 'invoices:invoices_payments_overview' %}" class="border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="text-2xl mb-2">🏦</div>
<h2 class="text-lg font-bold">Zahlungen</h2>
<p class="text-slate-400 text-sm mt-1">Zahlungsvorgänge verfolgen</p>
</a>
</div>
{% endblock %}
@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Rechnungswesen - Zahlungen{% endblock %}
{% block header_title %}Zahlungen{% endblock %}
{% block header_description %}Zahlungsvorgänge verfolgen{% endblock %}
{% block content %}
{% load django_tables2 %}
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="mb-6">
<h2 class="text-lg font-bold">Zahlungen</h2>
<p class="text-slate-400 text-sm mt-1">Alle Zahlungsvorgänge</p>
</div>
<div class="overflow-x-auto rounded-xl">
{% render_table table %}
</div>
</div>
{% endblock %}
@@ -15,7 +15,7 @@ Details
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">
Mahnung verwalten
</p>
@@ -28,8 +28,6 @@ Details
{% if reminder.status == "sent" %}
<a href="{% url 'invoices:reminders_download' reminder.id %}" class="btn btn-action">Download</a>
<a href="{% url 'invoices:reminders_cancel' reminder.id %}" class="btn btn-delete">Stornieren</a>
<a href="{% url 'invoices:reminders_paid' reminder.id %}" class="btn btn-action">Zahlung verbuchen</a>
<a href="{% url 'invoices:reminders_expire' reminder.id %}" class="btn btn-action">Überfällig</a>
{% endif %}
{% if reminder.status == "paid" or reminder.status == "canceled" or reminder.status == "expired" %}
<a href="{% url 'invoices:reminders_download' reminder.id %}" class="btn btn-action">Download</a>
@@ -11,7 +11,7 @@
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">Neue Mahnung erstellen</p>
</div>
@@ -24,7 +24,7 @@
</div>
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="mb-6">
<h2 class="text-xl font-bold">Mahnungen</h2>
<h2 class="text-lg font-bold">Mahnungen</h2>
<p class="text-slate-400 text-sm mt-1">Alle Mahnungen mit aktuellem Status</p>
</div>
<div class="overflow-x-auto rounded-xl">
+59 -14
View File
@@ -149,16 +149,15 @@ table {
.right {
text-align:right;
text-align: right !important;
}
.total-box {
margin-top:30px;
margin-left:auto;
width:250px;
width:300px;
}
@@ -178,7 +177,7 @@ table {
border-top:1px solid #cbd5e1;
margin-top:10px;
padding-top:10px;
font-size:16px;
font-size:14px;
font-weight:bold;
}
@@ -440,7 +439,7 @@ Betrag
Rechnung {{ reminder.invoice.invoice_number }}
</td>
<td>
<td class="right">
{{ invoice_amount|floatformat:2 }} €
</td>
@@ -459,7 +458,7 @@ Rechnung {{ reminder.invoice.invoice_number }}
({{ old_reminder.issue_date|date:"d.m.Y" }})
</td>
<td>
<td class="right">
{{ old_reminder.fee|floatformat:2 }} €
</td>
@@ -474,7 +473,7 @@ Rechnung {{ reminder.invoice.invoice_number }}
Aktuelle Mahngebühr
</td>
<td>
<td class="right">
{{ reminder.fee|floatformat:2 }} €
</td>
@@ -484,6 +483,32 @@ Rechnung {{ reminder.invoice.invoice_number }}
{% if payments %}
{% for payment in payments %}
<tr>
<td>
Zahlung vom {{ payment.date|date:"d.m.Y" }}
{% if payment.payment_method %}
({{ payment.payment_method }})
{% endif %}
</td>
<td class="right">
- {{ payment.amount|floatformat:2 }} €
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
@@ -494,12 +519,20 @@ Rechnung {{ reminder.invoice.invoice_number }}
<!-- GESAMTFORDERUNG -->
<div class="total-box">
<div class="total-row">
<span>
@@ -513,7 +546,6 @@ Offener Rechnungsbetrag
</div>
{% if previous_reminders or reminder.fee > 0 %}
<div class="total-row">
@@ -531,23 +563,36 @@ Mahngebühren
{% endif %}
<div class="total-row">
<span>
Bereits bezahlt
</span>
<span>
- {{ total_paid|floatformat:2 }} €
</span>
</div>
<div class="total-row total-final">
<span>
Gesamtforderung
Gesamt
</span>
<span>
{{ total_due|floatformat:2 }} €
{{ open_amount|floatformat:2 }} €
</span>
</div>
</div>
</div>
@@ -1,35 +0,0 @@
<form
id="save-form-{{ record.id }}"
method="post"
action="{% url 'invoices:items_edit' record.id %}">
{% csrf_token %}
</form>
<div class="flex gap-2">
<button
type="submit"
form="save-form-{{ record.id }}"
class="btn btn-save">
Speichern
</button>
<form
method="post"
action="{% url 'invoices:items_delete' record.id %}">
{% csrf_token %}
<button
type="submit"
class="btn btn-delete">
Löschen
</button>
</form>
</div>
@@ -1,6 +0,0 @@
<input
type="text"
name="description"
form="save-form-{{ record.id }}"
value="{{ record.description }}"
class="border rounded px-2 py-1 w-full">
@@ -0,0 +1,176 @@
<form method="post" action="{% url 'invoices:items_edit' item.id %}">
{% csrf_token %}
<div class="grid md:grid-cols-2 xl:grid-cols-3 gap-4">
<div>
<label class="block text-sm mb-1">
Typ
</label>
<select
name="item_type"
class="border rounded px-3 py-2 w-full">
{% for value,label in item.ItemType.choices %}
<option
value="{{ value }}"
{% if item.item_type == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm mb-1">
Beschreibung
</label>
<input
type="text"
name="description"
value="{{ item.description }}"
class="border rounded px-3 py-2 w-full">
</div>
<div>
<label class="block text-sm mb-1">
Kategorie
</label>
<select
name="category"
class="border rounded px-3 py-2 w-full">
<option value="">
---
</option>
{% for c in table.categories %}
<option
value="{{ c.id }}"
{% if item.category_id == c.id %}selected{% endif %}>
{{ c.name }}
</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm mb-1">
Beschreibung
</label>
<input
type="text"
name="description"
value="{{ item.description }}"
class="border rounded px-3 py-2 w-full">
</div>
<div>
<label class="block text-sm mb-1">
Einheit
</label>
<select
name="unit"
class="border rounded px-3 py-2 w-full">
<option value="">
---
</option>
{% for u in table.units %}
<option
value="{{ u.id }}"
{% if item.unit_id == u.id %}selected{% endif %}>
{{ u.name }}
</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm mb-1">
Einzelpreis Netto
</label>
<input
type="number"
step="0.01"
name="price_net"
value="{{ item.price_net }}"
class="border rounded px-3 py-2 w-full">
</div>
<div>
<label class="block text-sm mb-1">
MwSt. (%)
</label>
<input
type="number"
step="0.01"
name="tax_rate"
value="{{ item.tax_rate }}"
class="border rounded px-3 py-2 w-full">
</div>
<div>
<label class="block text-sm mb-1">
Einkaufspreis
</label>
<input
type="number"
step="0.01"
name="price_buy"
value="{{ item.price_buy }}"
class="border rounded px-3 py-2 w-full">
</div>
<div>
<label class="block text-sm mb-1">
Lieferant
</label>
<select
name="supplier"
class="border rounded px-3 py-2 w-full">
<option value="">
---
</option>
{% for s in table.suppliers %}
<option
value="{{ s.id }}"
{% if item.supplier_id == s.id %}selected{% endif %}>
{{ s.name }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="mt-5 flex gap-2">
<button
type="submit"
class="btn btn-save">
Speichern
</button>
<button
type="submit"
formaction="{% url 'invoices:items_delete' item.id %}"
class="btn btn-delete">
Löschen
</button>
</div>
</form>
@@ -1,16 +0,0 @@
<select
name="item_type"
form="save-form-{{ record.id }}"
class="border rounded px-2 py-1 w-full">
{% for v,l in table.types %}
<option
value="{{ v }}"
{% if record.item_type == v %}selected{% endif %}>
{{ l }}
</option>
{% endfor %}
</select>
@@ -1,7 +0,0 @@
<input
type="number"
step="0.01"
name="price_net"
form="save-form-{{ record.id }}"
value="{{ record.price_net }}"
class="border rounded px-2 py-1 w-full">
@@ -1,7 +0,0 @@
<input
type="number"
step="0.01"
name="tax_rate"
form="save-form-{{ record.id }}"
value="{{ record.tax_rate }}"
class="border rounded px-2 py-1 w-full">
@@ -1,20 +0,0 @@
<select
name="unit"
form="save-form-{{ record.id }}"
class="border rounded px-2 py-1 w-full">
{% for unit in table.units %}
<option
value="{{ unit.id }}"
{% if record.unit_id == unit.id %}selected{% endif %}>
{{ unit.name }}
</option>
{% endfor %}
</select>
+9 -5
View File
@@ -1,5 +1,5 @@
from django.urls import path
from . import views
from . import views, views_info
app_name = "invoices"
@@ -37,7 +37,6 @@ urlpatterns = [
path("invoices/<int:invoice_id>/download/", views.invoices_download, name="invoices_download"),
path("invoices/<int:invoice_id>/cancel/", views.invoices_cancel, name="invoices_cancel"),
path("invoices/<int:invoice_id>/paid/", views.invoices_paid, name="invoices_paid"),
path("invoices/<int:invoice_id>/overdue/", views.invoices_overdue, name="invoices_overdue"),
path("invoices/<int:invoice_id>/delete/", views.invoices_delete, name="invoices_delete"),
@@ -54,7 +53,6 @@ urlpatterns = [
path("offers/<int:offer_id>/download/", views.offers_download, name="offers_download"),
path("offers/<int:offer_id>/accept/", views.offers_accept, name="offers_accept"),
path("offers/<int:offer_id>/reject/", views.offers_reject, name="offers_reject"),
path("offers/<int:offer_id>/expire/", views.offers_expire, name="offers_expire"),
path("offers/<int:offer_id>/delete/", views.offers_delete, name="offers_delete"),
# Mahnungen
@@ -64,9 +62,9 @@ urlpatterns = [
path("reminders/<int:reminder_id>/download/", views.reminders_download, name="reminders_download"),
path("reminders/<int:reminder_id>/cancel/", views.reminders_cancel, name="reminders_cancel"),
path("reminders/<int:reminder_id>/delete/", views.reminders_delete, name="reminders_delete"),
path("reminders/<int:reminder_id>/expire/", views.reminders_expire, name="reminders_expire"),
path("reminders/<int:reminder_id>/paid/", views.reminders_paid, name="reminders_paid"),
# Zahlungen
path("payments/", views.invoices_payments_overview, name="invoices_payments_overview"),
# AJAX
path("ajax/customer/<int:customer_id>/contacts/", views.ajax_contacts, name="ajax_contacts"),
@@ -74,4 +72,10 @@ urlpatterns = [
path("ajax/project/<int:project_id>/invoice-data/", views.ajax_invoicedata, name="ajax_invoicedata"),
path("ajax/invoice/<int:invoice_id>/sources/", views.ajax_invoiceitemsources, name="ajax_invoice_item_sources"),
path("ajax/invoice-item-data/", views.ajax_invoice_item_data, name="ajax_invoice_item_data"),
path("ajax/invoice-info/", views_info.ajax_invoice_info, name="ajax_invoice_info"),
path("ajax/offer-info/", views_info.ajax_offer_info, name="ajax_offer_info"),
path("ajax/reminder-info/",views_info.ajax_reminder_info,name="ajax_reminder_info"),
path("ajax/customer-info/",views_info.ajax_customer_info, name="ajax_customer_info"),
path("ajax/kpi-info/", views_info.ajax_kpi_info, name="ajax_kpi_info"),
]
+305 -158
View File
@@ -1,8 +1,8 @@
from django.contrib.auth.decorators import login_required
from accounts.decorators import permission_required
from django.shortcuts import render, redirect, get_object_or_404
from .models import Customer, CustomerContact, CustomerLocation, Item, Invoice, InvoiceItem, Unit, Offer, OfferItem, Reminder
from .forms import CustomerForm, ItemForm, CustomerInvoiceForm, ProjectInvoiceForm, InvoiceItemForm, OfferForm, OfferItemForm, ReminderForm
from .models import Customer, CustomerContact, CustomerLocation, Item, Invoice, InvoiceItem, Unit, Offer, OfferItem, Reminder, InvoicePaymentAllocation, InvoicePayment
from .forms import CustomerForm, ItemForm, CustomerInvoiceForm, ProjectInvoiceForm, InvoiceItemForm, OfferForm, OfferItemForm, ReminderForm, InvoicePaymentForm
from datetime import date
from projects.models import Project
from django.http import JsonResponse, FileResponse
@@ -18,8 +18,9 @@ from django.utils import timezone
from weasyprint import HTML
import io
from django.db.models import Prefetch, Sum
from .tables import ItemTable, CustomerTable, CustomerArchivedTable, ReminderTable, InvoiceTable, InvoiceItemTable, OfferTable, OfferItemTable
from .tables import ItemTable, CustomerTable, CustomerArchivedTable, ReminderTable, InvoiceTable, InvoiceItemTable, OfferTable, OfferItemTable, InvoicePaymentTable
from django_tables2 import RequestConfig
from finance.models import Category, Supplier
def generate_document_number(template, number):
today = date.today()
@@ -416,6 +417,8 @@ def items_overview(request):
table = ItemTable(items)
table.types = Item.ItemType.choices
table.units = Unit.objects.order_by("name")
table.categories = Category.objects.order_by("name")
table.suppliers = Supplier.objects.order_by("name")
RequestConfig(
request,
@@ -450,13 +453,14 @@ def items_add(request):
@login_required
@permission_required("invoices_items_edit")
def items_edit(request, item_id):
if request.method != "POST":
print("Invalid request method for items_edit")
return redirect(
"invoices:items_overview"
)
item = get_object_or_404(
Item,
id=item_id
@@ -467,7 +471,8 @@ def items_edit(request, item_id):
request.POST,
instance=item
)
if not form.is_valid():
print(form.errors.as_json())
if form.is_valid():
form.save()
@@ -751,11 +756,12 @@ def ajax_invoice_item_data(request):
@login_required
@permission_required("invoices_invoices_view")
def invoices_invoices_overview(request):
status = request.GET.get("status")
invoices = Invoice.objects.select_related(
"customer",
"project"
).all()
invoices = Invoice.objects.select_related("customer", "project").all()
if status:
invoices = invoices.filter(status=status)
table = InvoiceTable(invoices)
@@ -1174,7 +1180,11 @@ def invoices_download(request, invoice_id):
)
number_settings.invoice_next_number += 1
number_settings.save(update_fields=["invoice_next_number"])
invoice.save(update_fields=["invoice_number"])
company = CompanySetting.objects.first()
invoice.is_small_business = company.is_small_business
invoice.save(update_fields=["invoice_number", "is_small_business"])
# ==========================
# PDF HTML
@@ -1290,40 +1300,87 @@ def invoices_cancel(request, invoice_id):
id=invoice_id,
)
if invoice.status == "canceled" and invoice.pdf_canceled_file:
# ==========================
# Existierendes Storno zurückgeben
# ==========================
if invoice.status == Invoice.Status.CANCELED and invoice.pdf_canceled_file:
return FileResponse(
invoice.pdf_canceled_file.open("rb"),
as_attachment=True,
filename=invoice.pdf_canceled_file.name.split("/")[-1],
)
# ==========================
# Nur finale Rechnungen stornierbar
# ==========================
if invoice.status not in [
Invoice.Status.SENT,
Invoice.Status.PAID,
Invoice.Status.OVERDUE,
]:
return redirect(
"invoices:invoices_detail",
invoice.id,
)
company = CompanySetting.objects.first()
number_settings = company.number_settings
# ==========================
# Stornonummer
# ==========================
invoice.cancel_number = generate_document_number(
number_settings.cancel_format,
number_settings.cancel_next_number,
)
number_settings.cancel_next_number += 1
number_settings.save(
update_fields=[
"cancel_next_number"
]
)
if invoice.status not in ["sent", "paid"]:
return redirect(
"invoices:invoices_detail",
invoice.id,
)
# ==========================
# Positionen
# ==========================
items = invoice.items.all()
# ==========================
# Summen
# Bereits bezahlt
# ==========================
total_net = Decimal("0.00")
paid_amount = invoice.paid_amount
# ==========================
# Rückerstattung berechnen
# ==========================
refund_amount = min(
paid_amount,
invoice.total_amount
)
show_service_date = any(
item.service_period_from
for item in items
)
tax_summary = defaultdict(
lambda: Decimal("0.00")
@@ -1331,42 +1388,19 @@ def invoices_cancel(request, invoice_id):
for item in items:
item_net = Decimal(
str(item.calculated_total_net)
)
total_net += item_net
tax_rate = Decimal(
str(item.unit_tax_rate)
)
item_net = item.calculated_total_net
tax_amount = (
item_net *
tax_rate /
item.unit_tax_rate /
Decimal("100")
)
tax_summary[tax_rate] += tax_amount
tax_summary[item.unit_tax_rate] += tax_amount
total_tax = sum(
tax_summary.values(),
Decimal("0.00")
)
total_gross = (
total_net +
total_tax
)
company = CompanySetting.objects.first()
original_status = invoice.status
show_service_date = any(
item.service_period_from
for item in items
)
# ==========================
# PDF HTML
# ==========================
html_string = render_to_string(
"invoices/invoices/pdf_cancel.html",
@@ -1376,42 +1410,54 @@ def invoices_cancel(request, invoice_id):
"company": company,
"cancel_date": timezone.now(),
"cancel_number": invoice.cancel_number,
"original_status": original_status,
"total_net": total_net,
"tax_summary": dict(tax_summary),
"total_tax": total_tax,
"total_gross": total_gross,
"paid_amount": paid_amount,
"refund_amount": refund_amount,
"total_net": invoice.total_net,
"total_tax": invoice.total_tax,
"total_gross": invoice.total_amount,
"show_service_date": show_service_date,
"tax_summary": dict(tax_summary),
},
)
# ==========================
# PDF erstellen
# ==========================
pdf_file = HTML(
string=html_string,
base_url=request.build_absolute_uri("/"),
).write_pdf()
filename = (
f"Stornorechnung-{invoice.cancel_number}.pdf"
)
invoice.pdf_canceled_file.save(
filename,
ContentFile(pdf_file),
save=False,
)
invoice.pdf_canceled_generated_at = timezone.now()
invoice.status = "canceled"
invoice.status = Invoice.Status.CANCELED
invoice.save(
update_fields=[
"pdf_canceled_file",
"pdf_canceled_generated_at",
"status",
'cancel_number',
"cancel_number",
]
)
return FileResponse(
io.BytesIO(pdf_file),
as_attachment=True,
@@ -1438,64 +1484,179 @@ def invoices_delete(request, invoice_id):
return redirect("invoices:invoices_invoices_overview",)
def create_invoice_income(invoice):
from finance.models import Income
if Income.objects.filter(
invoice=invoice
).exists():
return
last_payment = invoice.payments.order_by(
"-date"
).first()
if not last_payment:
return
Income.objects.create(
invoice=invoice,
amount=invoice.paid_invoice_amount,
date=last_payment.date,
name=f"Zahlung Rechnung {invoice.invoice_number}",
project=invoice.project,
payment_method=last_payment.payment_method,
created_by=last_payment.created_by,
)
def allocate_invoice_payment(payment):
remaining = payment.amount
invoice = payment.invoice
reminders = invoice.reminders.filter(
status__in=[
Reminder.Status.SENT,
Reminder.Status.EXPIRED,
]
).order_by("level")
for reminder in reminders:
already_paid = reminder.payment_allocations.aggregate(
total=Sum("amount")
)["total"] or Decimal("0.00")
open_fee = reminder.fee - already_paid
if open_fee <= 0:
continue
allocation_amount = min(
remaining,
open_fee
)
if allocation_amount > 0:
InvoicePaymentAllocation.objects.create(
payment=payment,
reminder=reminder,
amount=allocation_amount
)
remaining -= allocation_amount
if remaining <= 0:
break
if remaining > 0:
InvoicePaymentAllocation.objects.create(
payment=payment,
reminder=None,
amount=remaining
)
def update_invoice_payment_status(invoice):
# Mahnungen prüfen
reminders = invoice.reminders.filter(
status__in=[
Reminder.Status.SENT,
Reminder.Status.EXPIRED,
]
)
for reminder in reminders:
if reminder.paid_amount >= reminder.fee:
reminder.status = Reminder.Status.PAID
reminder.save(
update_fields=[
"status"
]
)
if invoice.paid_invoice_amount >= invoice.total_amount:
if invoice.status != Invoice.Status.PAID:
invoice.status = Invoice.Status.PAID
invoice.save(
update_fields=[
"status"
]
)
if invoice.project:
create_invoice_income(invoice)
@login_required
@permission_required("invoices_invoices_invoices_paid")
def invoices_paid(request, invoice_id):
invoice = get_object_or_404(
Invoice,
id=invoice_id,
)
if invoice.status != "sent":
if invoice.status not in [
Invoice.Status.SENT,
Invoice.Status.OVERDUE,
]:
return redirect(
"invoices:invoices_detail",
invoice_id=invoice.id,
)
invoice.status = "paid"
invoice.save(
update_fields=[
"status",
]
)
return redirect(
"invoices:invoices_detail",
invoice_id=invoice.id,
if request.method == "POST":
form = InvoicePaymentForm(request.POST)
if form.is_valid():
payment = form.save(commit=False)
payment.invoice = invoice
payment.created_by = request.user
payment.save()
allocate_invoice_payment(payment)
update_invoice_payment_status(invoice)
return redirect(
"invoices:invoices_detail",
invoice_id=invoice.id,
)
else:
print(form.errors)
else:
form = InvoicePaymentForm()
return render(
request,
"invoices/invoices/add_payment.html",
{
"form": form,
"invoice": invoice,
}
)
@login_required
@permission_required("invoices_invoices_invoices_overdue")
def invoices_overdue(request, invoice_id):
invoice = get_object_or_404(
Invoice,
id=invoice_id,
)
today = timezone.now().date()
if invoice.status != "sent":
return redirect(
"invoices:invoices_detail",
invoice_id=invoice.id,
)
if invoice.due_date and invoice.due_date < today:
invoice.status = "overdue"
invoice.save(
update_fields=[
"status",
]
)
return redirect(
"invoices:invoices_detail",
invoice_id=invoice.id,
)
@@ -1561,8 +1722,12 @@ def invoices_overdue(request, invoice_id):
@login_required
@permission_required("invoices_offers_view")
def invoices_offers_overview(request):
status = request.GET.get("status")
offers = Offer.objects.select_related("customer",).all()
if status:
offers = offers.filter(status=status)
table = OfferTable(offers)
RequestConfig(
@@ -2059,33 +2224,6 @@ def offers_reject(request, offer_id):
)
@login_required
@permission_required("invoices_offers_offers_status")
def offers_expire(request, offer_id):
offer = get_object_or_404(
Offer,
id=offer_id,
)
if offer.status != "sent":
return redirect(
"invoices:offers_detail",
offer_id=offer.id,
)
offer.status = "expired"
offer.save(
update_fields=[
"status",
]
)
return redirect(
"invoices:offers_detail",
offer_id=offer.id,
)
@@ -2163,9 +2301,10 @@ def offers_expire(request, offer_id):
@login_required
@permission_required("invoices_reminders_view")
def invoices_reminders_overview(request):
status = request.GET.get("status")
reminders = Reminder.objects.select_related("invoice").all()
if status:
reminders = reminders.filter(status=status)
table = ReminderTable(reminders)
RequestConfig(
@@ -2330,6 +2469,12 @@ def reminders_download(request, reminder_id):
invoice = reminder.invoice
payments = invoice.payments.order_by("date", "created_at")
total_paid = sum(
payment.amount
for payment in payments
)
# ==========================
# Rechnungsbetrag berechnen
@@ -2382,6 +2527,7 @@ def reminders_download(request, reminder_id):
+ reminder.fee
)
open_amount = total_due - total_paid
total_fees = (total_previous_fees + reminder.fee)
company = CompanySetting.objects.first()
@@ -2429,11 +2575,13 @@ def reminders_download(request, reminder_id):
"invoice_amount": invoice_amount,
"previous_reminders": previous_reminders,
"total_previous_fees": total_previous_fees,
"total_fees": total_fees,
"payments": payments,
"total_paid": total_paid,
"total_due": total_due,
"total_fees": total_due,
"open_amount": open_amount,
}
)
# ==========================
# PDF erstellen
# ==========================
@@ -2475,50 +2623,49 @@ def reminders_download(request, reminder_id):
@login_required
@permission_required("invoices_reminders_reminders_paid")
def reminders_paid(request, reminder_id):
reminder = get_object_or_404(
Reminder,
id=reminder_id,
)
if reminder.status != "sent":
return redirect(
"invoices:reminders_detail",
reminder_id=reminder.id,
)
reminder.status = "paid"
reminder.save(update_fields=["status",])
reminder.invoice.status = "paid"
reminder.invoice.save(update_fields=["status",])
return redirect(
"invoices:reminders_detail",
reminder_id=reminder.id,
)
@login_required
@permission_required("invoices_reminders_reminders_expire")
def reminders_expire(request, reminder_id):
reminder = get_object_or_404(
Reminder,
id=reminder_id,
@permission_required("invoices_payments_view")
def invoices_payments_overview(request):
payments = InvoicePayment.objects.select_related(
"invoice",
"invoice__customer",
"payment_method",
"created_by",
).order_by(
"-date"
)
if reminder.status != "sent":
return redirect(
"invoices:reminders_detail",
reminder_id=reminder.id,
)
reminder.status = "expired"
reminder.save(update_fields=["status",])
table = InvoicePaymentTable(
payments
)
return redirect(
"invoices:reminders_detail",
reminder_id=reminder.id,
RequestConfig(
request,
paginate={
"per_page": 25
}
).configure(
table
)
return render(
request,
"invoices/payments/overview.html",
{
"table": table,
}
)
+81
View File
@@ -0,0 +1,81 @@
from django.contrib.auth.decorators import login_required, permission_required
from django.db.models import Count, Q, Sum
from django.http import JsonResponse
from django.utils import timezone
from datetime import timedelta
from .models import Invoice, Offer, Reminder, Customer
@login_required
@permission_required("invoices_invoices_info")
def ajax_invoice_info(request):
now = timezone.now()
data = (
Invoice.objects
.filter(issue_date__year=now.year, issue_date__month=now.month)
.values("status")
.annotate(count=Count("id"))
)
return JsonResponse({item["status"]: item["count"] for item in data})
@login_required
@permission_required("invoices_offers_info")
def ajax_offer_info(request):
now = timezone.now()
data = (
Offer.objects
.filter(issue_date__year=now.year, issue_date__month=now.month)
.values("status")
.annotate(count=Count("id"))
)
return JsonResponse({item["status"]: item["count"] for item in data})
@login_required
@permission_required("invoices_reminders_info")
def ajax_reminder_info(request):
now = timezone.now()
data = (
Reminder.objects
.filter(issue_date__year=now.year, issue_date__month=now.month)
.values("status")
.annotate(count=Count("id"))
)
return JsonResponse({item["status"]: item["count"] for item in data})
@login_required
@permission_required("invoices_info")
def ajax_kpi_info(request):
today = timezone.now().date()
overdue_invoices = Invoice.objects.filter(status="overdue").count()
open_invoices = Invoice.objects.filter(status="sent").count()
open_offers = Offer.objects.filter(status="sent").count()
open_reminders = Reminder.objects.filter(status="sent").count()
overdue_amount = (
Invoice.objects
.filter(status="overdue")
.aggregate(total=Sum("items__unit_price_net"))["total"] or 0
)
return JsonResponse({
"overdue_invoices": overdue_invoices,
"open_invoices": open_invoices,
"open_offers": open_offers,
"open_reminders": open_reminders,
})
@login_required
@permission_required("invoices_customers_info")
def ajax_customer_info(request):
start_of_week = timezone.now().date() - timedelta(days=timezone.now().weekday())
return JsonResponse({
"total": Customer.objects.count(),
"new_this_week": Customer.objects.filter(created_at__date__gte=start_of_week).count(),
"companies": Customer.objects.filter(customer_type="company").count(),
"private": Customer.objects.filter(customer_type="private").count(),
})