Skip to content

Commit 094ef69

Browse files
authored
feature: Discounts (#244)
feature: Added invoice discounts
1 parent 32fb9fa commit 094ef69

File tree

11 files changed

+333
-21
lines changed

11 files changed

+333
-21
lines changed

backend/api/base/modal.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ def open_modal(request: HttpRequest, modal_name, context_type=None, context_valu
4444
# context["to_city"] = invoice.client_city
4545
# context["to_county"] = invoice.client_county
4646
# context["to_country"] = invoice.client_country
47+
elif context_type == "invoice":
48+
try:
49+
invoice = Invoice.objects.get(id=context_value)
50+
if invoice.has_access(request.user):
51+
context["invoice"] = invoice
52+
except Invoice.DoesNotExist:
53+
...
4754
else:
4855
context[context_type] = context_value
4956

backend/api/invoices/edit.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from datetime import datetime
2+
from typing import NoReturn
23

34
from django.contrib import messages
45
from django.http import HttpRequest, JsonResponse, HttpResponse
@@ -99,12 +100,55 @@ def change_status(request: HttpRequest, invoice_id: int, status: str) -> HttpRes
99100
return render(request, "pages/invoices/dashboard/_modify_payment_status.html", {"status": status, "invoice_id": invoice_id})
100101

101102

103+
@require_POST
104+
def edit_discount(request: HttpRequest, invoice_id: str):
105+
discount_type = "percentage" if request.POST.get("discount_type") == "on" else "amount"
106+
discount_amount_str: str = request.POST.get("discount_amount")
107+
percentage_amount_str: str = request.POST.get("percentage_amount")
108+
109+
if not request.htmx:
110+
return redirect("invoices:dashboard")
111+
112+
try:
113+
invoice: Invoice = Invoice.objects.get(id=invoice_id)
114+
except Invoice.DoesNotExist:
115+
return return_message(request, "Invoice not found", False)
116+
117+
if not invoice.has_access(request.user):
118+
return return_message(request, "You don't have permission to make changes to this invoice.", False)
119+
120+
if discount_type == "percentage":
121+
try:
122+
percentage_amount = int(percentage_amount_str)
123+
if percentage_amount < 0 or percentage_amount > 100:
124+
raise ValueError
125+
except ValueError:
126+
return return_message(request, "Please enter a valid percentage amount (between 0 and 100)", False)
127+
invoice.discount_percentage = percentage_amount
128+
else:
129+
try:
130+
discount_amount = int(discount_amount_str)
131+
if discount_amount < 0:
132+
raise ValueError
133+
except ValueError:
134+
return return_message(request, "Please enter a valid discount amount", False)
135+
invoice.discount_amount = discount_amount
136+
137+
invoice.save()
138+
139+
messages.success(request, "Discount was applied successfully")
140+
141+
response = render(request, "base/toasts.html")
142+
response["HX-Trigger"] = "update_invoice"
143+
return response
144+
145+
102146
def return_message(request: HttpRequest, message: str, success: bool = True) -> HttpResponse:
103147
send_message(request, message, success)
104148
return render(request, "base/toasts.html")
105149

106150

107-
def send_message(request: HttpRequest, message: str, success: bool = False) -> HttpResponse:
151+
def send_message(request: HttpRequest, message: str, success: bool = False) -> NoReturn:
108152
if success:
109153
messages.success(request, message)
110154
else:

backend/api/invoices/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
name="edit",
3232
),
3333
path("edit/<int:invoice_id>/set_status/<str:status>/", edit.change_status, name="edit status"),
34+
path("edit/<str:invoice_id>/discount/", edit.edit_discount, name="edit discount"),
3435
path("fetch/", fetch.fetch_all_invoices, name="fetch"),
3536
path("schedules/receive/", schedule.receive_scheduled_invoice, name="receive_scheduled_invoice"),
3637
path("create_schedule/", schedule.create_schedule, name="create_schedule"),
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Generated by Django 5.0.3 on 2024-03-29 20:00
2+
3+
import django.core.validators
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("backend", "0025_alter_invoiceonetimeschedule_stored_schedule_arn"),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name="invoice",
16+
name="discount_amount",
17+
field=models.DecimalField(decimal_places=2, default=0, max_digits=15),
18+
),
19+
migrations.AddField(
20+
model_name="invoice",
21+
name="discount_percentage",
22+
field=models.DecimalField(
23+
decimal_places=2, default=0, max_digits=5, validators=[django.core.validators.MaxValueValidator(100)]
24+
),
25+
),
26+
]

backend/models.py

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
from decimal import Decimal
12
from uuid import uuid4
23

34
from django.contrib.auth.hashers import make_password, check_password
45
from django.contrib.auth.models import UserManager, AbstractUser, AnonymousUser
6+
from django.core.validators import MaxValueValidator
57
from django.db import models
68
from django.db.models import Count
79
from django.utils import timezone
@@ -303,9 +305,23 @@ class Invoice(models.Model):
303305
date_due = models.DateField()
304306
date_issued = models.DateField(blank=True, null=True)
305307

308+
discount_amount = models.DecimalField(max_digits=15, default=0, decimal_places=2)
309+
discount_percentage = models.DecimalField(default=0, max_digits=5, decimal_places=2, validators=[MaxValueValidator(100)])
310+
306311
class Meta:
307312
constraints = [USER_OR_ORGANIZATION_CONSTRAINT()]
308313

314+
def __str__(self):
315+
invoice_id = self.invoice_id or self.id
316+
if self.client_name:
317+
client = self.client_name
318+
elif self.client_to:
319+
client = self.client_to.name
320+
else:
321+
client = "Unknown Client"
322+
323+
return f"Invoice #{invoice_id} for {client}"
324+
309325
@property
310326
def dynamic_payment_status(self):
311327
if self.date_due and timezone.now().date() > self.date_due and self.payment_status == "pending":
@@ -328,29 +344,50 @@ def get_to_details(self) -> tuple[str, dict[str, str]]:
328344
"company": self.client_company,
329345
}
330346

331-
def __str__(self):
332-
invoice_id = self.invoice_id or self.id
333-
if self.client_name:
334-
client = self.client_name
335-
elif self.client_to:
336-
client = self.client_to.name
337-
else:
338-
client = "Unknown Client"
339-
340-
return f"Invoice #{invoice_id} for {client}"
341-
342347
def get_subtotal(self):
343348
subtotal = 0
344349
for item in self.items.all():
345350
subtotal += item.get_total_price()
346351
return round(subtotal, 2)
347352

353+
def get_tax(self, amount: float = 0.00) -> float:
354+
amount = amount or self.get_subtotal()
355+
if self.vat_number:
356+
return round(amount * 0.2, 2)
357+
return 0
358+
359+
def get_percentage_amount(self, subtotal: float = 0.00) -> Decimal:
360+
total = subtotal or self.get_subtotal()
361+
362+
if self.discount_percentage > 0:
363+
return round(total * (self.discount_percentage / 100), 2)
364+
return Decimal(0)
365+
348366
def get_total_price(self):
349-
total = 0
350-
subtotal = self.get_subtotal()
351-
total = subtotal * 1.2 if self.vat_number else subtotal
367+
total = self.get_subtotal() or 0.00
368+
369+
total -= self.get_percentage_amount()
370+
371+
discount_amount = self.discount_amount
372+
373+
total -= discount_amount
374+
375+
if 0 > total:
376+
total = 0
377+
else:
378+
total -= self.get_tax(total)
379+
352380
return round(total, 2)
353381

382+
def has_access(self, user: User) -> bool:
383+
if not user.is_authenticated:
384+
return False
385+
386+
if user.logged_in_as_team:
387+
return self.organization == user.logged_in_as_team
388+
else:
389+
return self.user == user
390+
354391

355392
class InvoiceURL(models.Model):
356393
uuid = ShortUUIDField(length=8, primary_key=True)

frontend/templates/base/_head.html

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@
5656
htmx.config.globalViewTransitions = true
5757
htmx.config.useTemplateFragments = true // for swapping of table items
5858
Alpine.start()
59-
6059
</script>
6160
{{ analytics|safe }}
6261
{% load tz_detect %}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
{% component_block "modal" id="modal_invoices_edit_discount" start_open="true" title="Edit invoice discount" %}
2+
{% fill "content" %}
3+
<form class="py-4"
4+
id="modal_invoices_edit_discount-form"
5+
hx-post="{% url 'api:invoices:edit discount' invoice_id=invoice.id %}"
6+
hx-swap="none">
7+
{% csrf_token %}
8+
<div class="form-control my-4">
9+
<label class="label cursor-pointer">
10+
<span class="label-text">Fixed Amount</span>
11+
<input data-discount="checkbox"
12+
name="discount_type"
13+
type="checkbox"
14+
class="toggle"
15+
checked />
16+
<span class="label-text">Percentage</span>
17+
</label>
18+
</div>
19+
<script>
20+
const checkbox = document.querySelector('input[data-discount="checkbox"]');
21+
const amount_label = document.querySelector('label[data-discount="amount"]')
22+
const percentage_label = document.querySelector('label[data-discount="percentage"]')
23+
24+
checkbox.addEventListener("change", function () {
25+
if (checkbox.checked) {
26+
amount_label.classList.add("hidden")
27+
percentage_label.classList.remove("hidden")
28+
amount_label.querySelector("input").required = false;
29+
percentage_label.querySelector("input").required = true;
30+
amount_label.querySelector("input").pattern = "[0-9]+";
31+
percentage_label.querySelector("input").removeAttribute("pattern");
32+
} else {
33+
percentage_label.classList.add("hidden")
34+
amount_label.classList.remove("hidden")
35+
amount_label.querySelector("input").required = true;
36+
percentage_label.querySelector("input").required = false;
37+
amount_label.querySelector("input").pattern = "[0-9]+";
38+
percentage_label.querySelector("input").removeAttribute("pattern");
39+
}
40+
})
41+
</script>
42+
<label data-discount="percentage"
43+
class="input input-bordered flex items-center gap-2">
44+
<i class="fa fa-solid fa-percentage mr-2"></i>
45+
<input required
46+
type="text"
47+
class="grow"
48+
placeholder="Percentage"
49+
value="{{ invoice.discount_percentage|floatformat:0 }}"
50+
name="percentage_amount"
51+
pattern="[0-9]+" />
52+
</label>
53+
<label data-discount="amount"
54+
class="input input-bordered flex items-center gap-2 hidden">
55+
<i class="fa fa-solid fa-pound-sign mr-2"></i>
56+
<input required
57+
type="text"
58+
class="grow"
59+
value="{{ invoice.discount_percentage|floatformat:0 }}"
60+
placeholder="Amount"
61+
name="discount_amount"
62+
pattern="[0-9]+" />
63+
</label>
64+
<div class="modal-action">
65+
<button type="submit"
66+
id="modal_invoices_edit_discount-submit"
67+
class="btn btn-primary"
68+
_="on click if #modal_invoices_edit_discount-form.checkValidity() call #modal_invoices_edit_discount.close() end">
69+
Save
70+
</button>
71+
<button type="reset" class="btn btn-error">Reset</button>
72+
<button type="button"
73+
_="on click call #modal_invoices_edit_discount.close()"
74+
class="btn">Close</button>
75+
</div>
76+
</form>
77+
{% endfill %}
78+
{% endcomponent_block %}

frontend/templates/pages/invoices/dashboard/manage.html

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,11 @@ <h1 class="text-center col-span-2">Invoice #{{ invoice.id }}</h1>
2424
<div class="card bg-base-100 shadow-xl w-full p-6 flex-col gap-y-4">
2525
<div class="flex flex-wrap gap-y-4">
2626
<button class="btn btn-md btn-outline btn-default grow loading-htmx mr-4"
27+
data-htmx="preview-button"
2728
hx-target="#container"
2829
hx-get="{% url "api:invoices:tab preview" invoice_id=invoice.id %}"
2930
hx-swap="innerHTML"
30-
hx-trigger="load,click,queue:last"
31+
hx-trigger="load,click,update_invoice from:body,queue:last"
3132
hx-indicator="this">
3233
<span class="loading-htmx-text">
3334
<i class="fa-solid fa-file-pdf"></i>
@@ -90,6 +91,15 @@ <h1 class="text-center col-span-2">Invoice #{{ invoice.id }}</h1>
9091
</li>
9192
</ul>
9293
</div>
94+
<button class="btn btn-secondary"
95+
hx-trigger="click once"
96+
hx-swap="beforeend"
97+
hx-target="#modal_container"
98+
_="on click call modal_invoices_edit_discount.showModal()"
99+
hx-get="{% url "api:base:modal retrieve with context" context_type="invoice" context_value=invoice.id modal_name="invoices_edit_discount" %}">
100+
<i class="fa fa-solid fa-pound-sign mr-2"></i>
101+
Edit Discount
102+
</button>
93103
</div>
94104
</div>
95105
</div>

frontend/templates/pages/invoices/view/invoice.html

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,25 @@ <h2 class="text-2xl font-bold text-gray-800">INVOICE</h2>
102102
</td>
103103
</tr>
104104
{% endif %}
105-
{% if invoice.discount %}
105+
{% if invoice.discount_percentage %}
106106
<tr class="bg-gray-50">
107107
<th colspan="3"
108108
class="px-6 py-3 text-left text-sm font-medium text-gray-500 uppercase tracking-wider">
109109
Discount
110110
</th>
111-
<td class="px-6 py-3 text-right text-sm font-medium text-gray-500 uppercase tracking-wider">
112-
-{{ invoice.discount }}%
111+
<td class="px-6 py-3 text-right text-sm font-medium text-error uppercase tracking-wider text-balance">
112+
{{ invoice.discount_percentage }}% off (-{{ currency_symbol }}{{ invoice.get_percentage_amount }})
113+
</td>
114+
</tr>
115+
{% endif %}
116+
{% if invoice.discount_amount %}
117+
<tr class="bg-gray-50">
118+
<th colspan="3"
119+
class="px-6 py-3 text-left text-sm font-medium text-gray-500 uppercase tracking-wider">
120+
{% if not invoice.discount_percentage %}Discount{% endif %}
121+
</th>
122+
<td class="px-6 py-3 text-right text-sm font-medium text-error uppercase tracking-wider">
123+
-{{ currency_symbol }}{{ invoice.discount_amount }}
113124
</td>
114125
</tr>
115126
{% endif %}

frontend/templates/pages/invoices/view/invoice_page.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
Print
5353
</button>
5454
<a class="btn btn-sm gradient-btn me-8"
55-
href="{% url "invoices: manage_access" invoice_id=invoice.id %}">Share Invoice</a>
55+
href="{% url "invoices:manage_access" invoice_id=invoice.id %}">Share Invoice</a>
5656
<div class="flex items-center">
5757
<button onclick="document.getElementById('status_banner').remove();"
5858
type="button"

0 commit comments

Comments
 (0)