Skip to content

Commit

Permalink
feature: Added invoice discounts
Browse files Browse the repository at this point in the history
  • Loading branch information
TreyWW committed Mar 30, 2024
1 parent f3d528c commit e70a007
Show file tree
Hide file tree
Showing 9 changed files with 231 additions and 32 deletions.
7 changes: 7 additions & 0 deletions backend/api/base/modal.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ def open_modal(request: HttpRequest, modal_name, context_type=None, context_valu
# context["to_city"] = invoice.client_city
# context["to_county"] = invoice.client_county
# context["to_country"] = invoice.client_country
elif context_type == "invoice":
try:
invoice = Invoice.objects.get(id=context_value)
if invoice.has_access(request.user):
context["invoice"] = invoice
except Invoice.DoesNotExist:
...
else:
context[context_type] = context_value

Expand Down
46 changes: 45 additions & 1 deletion backend/api/invoices/edit.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import datetime
from typing import NoReturn

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


@require_POST
def edit_discount(request: HttpRequest, invoice_id: str):
discount_type = "percentage" if request.POST.get("discount_type") == "on" else "amount"
discount_amount_str: str = request.POST.get("discount_amount")
percentage_amount_str: str = request.POST.get("percentage_amount")

if not request.htmx:
return redirect("invoices:dashboard")

try:
invoice: Invoice = Invoice.objects.get(id=invoice_id)
except Invoice.DoesNotExist:
return return_message(request, "Invoice not found", False)

if not invoice.has_access(request.user):
return return_message(request, "You don't have permission to make changes to this invoice.", False)

if discount_type == "percentage":
try:
percentage_amount = int(percentage_amount_str)
if percentage_amount < 0 or percentage_amount > 100:
raise ValueError
except ValueError:
return return_message(request, "Please enter a valid percentage amount (between 0 and 100)", False)
invoice.discount_percentage = percentage_amount
else:
try:
discount_amount = int(discount_amount_str)
if discount_amount < 0:
raise ValueError
except ValueError:
return return_message(request, "Please enter a valid discount amount", False)
invoice.discount_amount = discount_amount

invoice.save()

messages.success(request, "Discount was applied successfully")

response = render(request, "base/toasts.html")
response["HX-Trigger"] = "update_invoice"
return response


def return_message(request: HttpRequest, message: str, success: bool = True) -> HttpResponse:
send_message(request, message, success)
return render(request, "base/toasts.html")


def send_message(request: HttpRequest, message: str, success: bool = False) -> HttpResponse:
def send_message(request: HttpRequest, message: str, success: bool = False) -> NoReturn:
if success:
messages.success(request, message)
else:
Expand Down
1 change: 1 addition & 0 deletions backend/api/invoices/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
name="edit",
),
path("edit/<int:invoice_id>/set_status/<str:status>/", edit.change_status, name="edit status"),
path("edit/<str:invoice_id>/discount/", edit.edit_discount, name="edit discount"),
path("fetch/", fetch.fetch_all_invoices, name="fetch"),
path("schedules/receive/", schedule.receive_scheduled_invoice, name="receive_scheduled_invoice"),
path("create_schedule/", schedule.create_schedule, name="create_schedule"),
Expand Down
32 changes: 20 additions & 12 deletions backend/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import decimal
from decimal import Decimal
from uuid import uuid4

Expand Down Expand Up @@ -312,6 +311,17 @@ class Invoice(models.Model):
class Meta:
constraints = [USER_OR_ORGANIZATION_CONSTRAINT()]

def __str__(self):
invoice_id = self.invoice_id or self.id
if self.client_name:
client = self.client_name
elif self.client_to:
client = self.client_to.name
else:
client = "Unknown Client"

return f"Invoice #{invoice_id} for {client}"

@property
def dynamic_payment_status(self):
if self.date_due and timezone.now().date() > self.date_due and self.payment_status == "pending":
Expand All @@ -334,17 +344,6 @@ def get_to_details(self) -> tuple[str, dict[str, str]]:
"company": self.client_company,
}

def __str__(self):
invoice_id = self.invoice_id or self.id
if self.client_name:
client = self.client_name
elif self.client_to:
client = self.client_to.name
else:
client = "Unknown Client"

return f"Invoice #{invoice_id} for {client}"

def get_subtotal(self):
subtotal = 0
for item in self.items.all():
Expand Down Expand Up @@ -380,6 +379,15 @@ def get_total_price(self):

return round(total, 2)

def has_access(self, user: User) -> bool:
if not user.is_authenticated:
return False

if user.logged_in_as_team:
return self.organization == user.logged_in_as_team
else:
return self.user == user


class InvoiceURL(models.Model):
uuid = ShortUUIDField(length=8, primary_key=True)
Expand Down
64 changes: 51 additions & 13 deletions frontend/templates/modals/invoices_edit_discount.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,64 @@
{% fill "content" %}
<form class="py-4"
id="modal_invoices_edit_discount-form"
hx-post="{% url 'api:invoices:services add' %}"
hx-include="#services_table_body"
hx-swap="innerHTML"
hx-target="#services_table_body">
hx-post="{% url 'api:invoices:edit discount' invoice_id=invoice.id %}"
hx-swap="none">
{% csrf_token %}


<div class="form-control my-4">
<label class="label cursor-pointer">
<span class="label-text">Fixed Amount</span>
<input type="checkbox" class="toggle" checked
_="on change if me.checked add @hidden to document.querySelector('[data-discount=\"\\"]')" />
<input data-discount="checkbox"
name="discount_type"
type="checkbox"
class="toggle"
checked />
<span class="label-text">Percentage</span>
</label>
</div>

<label class="input input-bordered flex items-center gap-2">
<i data-discount="amount" class="fa fa-solid fa-pound-sign mr-2"></i>
<i data-discount="percentage" class="hidden fa fa-solid fa-percentage mr-2"></i>
<input type="text" class="grow" placeholder="Amount" pattern="[0-9]" />
<script>
const checkbox = document.querySelector('input[data-discount="checkbox"]');
const amount_label = document.querySelector('label[data-discount="amount"]')
const percentage_label = document.querySelector('label[data-discount="percentage"]')

checkbox.addEventListener("change", function () {
if (checkbox.checked) {
amount_label.classList.add("hidden")
percentage_label.classList.remove("hidden")
amount_label.querySelector("input").required = false;
percentage_label.querySelector("input").required = true;
amount_label.querySelector("input").pattern = "[0-9]+";
percentage_label.querySelector("input").removeAttribute("pattern");
} else {
percentage_label.classList.add("hidden")
amount_label.classList.remove("hidden")
amount_label.querySelector("input").required = true;
percentage_label.querySelector("input").required = false;
amount_label.querySelector("input").pattern = "[0-9]+";
percentage_label.querySelector("input").removeAttribute("pattern");
}
})
</script>
<label data-discount="percentage"
class="input input-bordered flex items-center gap-2">
<i class="fa fa-solid fa-percentage mr-2"></i>
<input required
type="text"
class="grow"
placeholder="Percentage"
value="{{ invoice.discount_percentage|floatformat:0 }}"
name="percentage_amount"
pattern="[0-9]+" />
</label>
<label data-discount="amount"
class="input input-bordered flex items-center gap-2 hidden">
<i class="fa fa-solid fa-pound-sign mr-2"></i>
<input required
type="text"
class="grow"
value="{{ invoice.discount_percentage|floatformat:0 }}"
placeholder="Amount"
name="discount_amount"
pattern="[0-9]+" />
</label>
<div class="modal-action">
<button type="submit"
Expand Down
5 changes: 3 additions & 2 deletions frontend/templates/pages/invoices/dashboard/manage.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ <h1 class="text-center col-span-2">Invoice #{{ invoice.id }}</h1>
<div class="card bg-base-100 shadow-xl w-full p-6 flex-col gap-y-4">
<div class="flex flex-wrap gap-y-4">
<button class="btn btn-md btn-outline btn-default grow loading-htmx mr-4"
data-htmx="preview-button"
hx-target="#container"
hx-get="{% url "api:invoices:tab preview" invoice_id=invoice.id %}"
hx-swap="innerHTML"
hx-trigger="load,click,queue:last"
hx-trigger="load,click,update_invoice from:body,queue:last"
hx-indicator="this">
<span class="loading-htmx-text">
<i class="fa-solid fa-file-pdf"></i>
Expand Down Expand Up @@ -96,7 +97,7 @@ <h1 class="text-center col-span-2">Invoice #{{ invoice.id }}</h1>
hx-swap="beforeend"
hx-target="#modal_container"
_="on click call modal_invoices_edit_discount.showModal()"
hx-get="{% url "api:base:modal retrieve with context" context_type="invoice" context_value="{{ invoice.id }}" modal_name="invoices_edit_discount" %}">
hx-get="{% url "api:base:modal retrieve with context" context_type="invoice" context_value=invoice.id modal_name="invoices_edit_discount" %}">
<i class="fa fa-solid fa-pound-sign mr-2"></i>
Edit Discount
</button>
Expand Down
6 changes: 3 additions & 3 deletions frontend/templates/pages/invoices/view/invoice.html
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,14 @@ <h2 class="text-2xl font-bold text-gray-800">INVOICE</h2>
</td>
</tr>
{% endif %}
{{% if invoice.discount_percentage %}
{% if invoice.discount_percentage %}
<tr class="bg-gray-50">
<th colspan="3"
class="px-6 py-3 text-left text-sm font-medium text-gray-500 uppercase tracking-wider">
Discount
</th>
<td class="px-6 py-3 text-right text-sm font-medium text-error uppercase tracking-wider">
{{ invoice.discount_percentage }}% off (-{{ currency_symbol }}{{ invoice.get_percentage_discount_amt }})
<td class="px-6 py-3 text-right text-sm font-medium text-error uppercase tracking-wider text-balance">
{{ invoice.discount_percentage }}% off (-{{ currency_symbol }}{{ invoice.get_percentage_amount }})
</td>
</tr>
{% endif %}
Expand Down
3 changes: 2 additions & 1 deletion frontend/templates/pages/invoices/view/invoice_page.html
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@
Print
</button>
<a class="btn btn-sm gradient-btn me-8"
href="{% url "invoices: manage_access" invoice_id=invoice.id %}">Share Invoice</a>
href="{% url "invoices:manage_access" invoice_id=invoice.id %}">Share Invoice
</a>
<div class="flex items-center">
<button onclick="document.getElementById('status_banner').remove();"
type="button"
Expand Down
99 changes: 99 additions & 0 deletions tests/api/test_invoices.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import random

from django.urls import reverse, resolve
from model_bakery import baker

from backend.models import Invoice
from tests.handler import ViewTestCase, assert_url_matches_view


Expand Down Expand Up @@ -111,3 +113,100 @@ def test_matches_with_urls_view(self):
#
# response_content = json.loads(response.content.decode("utf-8"))
# self.assertEqual(response_content.get("message"), "Invoice not found")


class InvoicesEditDiscount(ViewTestCase):
def setUp(self):
super().setUp()
self.url_path = "/api/invoices/edit/discount/"
self.url_name = "api:invoices:edit discount"
self.view_function_path = "backend.api.invoices.edit.edit_discount"
self.invoice: Invoice = baker.make("backend.Invoice", user=self.log_in_user)

def test_302_for_all_normal_get_requests(self):
# Ensure that non-HTMX GET requests are redirected to the login page

response = self.client.post(reverse(self.url_name, kwargs={"invoice_id": self.invoice.id}))
self.assertRedirects(response, f"/auth/login/?next=/api/invoices/edit/{self.invoice.id}/discount/", 302)

# Ensure that authenticated users with HTMX headers are redirected to the invoices dashboard
self.login_user()
response = self.client.post(reverse(self.url_name, kwargs={"invoice_id": self.invoice.id}))
self.assertRedirects(response, "/dashboard/invoices/", 302)

def test_valid_edit_percentage(self):
self.login_user()
amount = 20

response = self.client.post(
reverse(self.url_name, kwargs={"invoice_id": self.invoice.id}),
{"discount_type": "on", "percentage_amount": amount},
**self.htmx_headers,
)

self.assertTrue(response.status_code, 200)

messages = self.get_all_messages(response)
self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "Discount was applied successfully")
self.invoice.refresh_from_db()
self.assertEqual(self.invoice.discount_percentage, amount)

def test_invalid_edit_percentages(self):
self.login_user()
amounts = [-1, -100, "", 101, 10000]

for amount in amounts:
response = self.client.post(
reverse(self.url_name, kwargs={"invoice_id": self.invoice.id}),
{"discount_type": "on", "percentage_amount": amount},
**self.htmx_headers,
)

self.assertTrue(response.status_code, 400)

messages = self.get_all_messages(response)
self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "Please enter a valid percentage amount (between 0 and 100)")

def test_valid_edit_amount(self):
self.login_user()
amount = 20

response = self.client.post(
reverse(self.url_name, kwargs={"invoice_id": self.invoice.id}),
{"discount_type": "off", "discount_amount": amount},
**self.htmx_headers,
)

self.assertTrue(response.status_code, 200)

messages = self.get_all_messages(response)
self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "Discount was applied successfully")
self.invoice.refresh_from_db()
self.assertEqual(self.invoice.discount_amount, amount)

def test_invalid_edit_amounts(self):
self.login_user()
amounts = [-1, -100, "", "abc"]

for amount in amounts:
response = self.client.post(
reverse(self.url_name, kwargs={"invoice_id": self.invoice.id}),
{"discount_type": "off", "discount_amount": amount},
**self.htmx_headers,
)

self.assertTrue(response.status_code, 400)

messages = self.get_all_messages(response)
self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "Please enter a valid discount amount")

def test_matches_with_urls_view(self):
resolved_func = resolve(f"/api/invoices/edit/{self.invoice.id}/discount/").func
resolved_func_name = f"{resolved_func.__module__}.{resolved_func.__name__}"

self.assertEqual(reverse(self.url_name, kwargs={"invoice_id": self.invoice.id}), f"/api/invoices/edit/{self.invoice.id}/discount/")
self.assertEqual(resolved_func_name, self.view_function_path)

0 comments on commit e70a007

Please sign in to comment.