From c5f6b2fd60da5f1fe908454f75c6718f3d925de1 Mon Sep 17 00:00:00 2001 From: Trey <73353716+TreyWW@users.noreply.github.com> Date: Sun, 29 Sep 2024 16:16:41 +0100 Subject: [PATCH] Feature/draft invoices (#503) * removed backend sorting+filtering, added frontend js class that handles tables * added docs, removed dashboard page test table, added draft statuses Signed-off-by: Trey <73353716+TreyWW@users.noreply.github.com> --- assets/scripts/init.js | 1 - assets/scripts/tableify.js | 228 ++++++++++++++++++ backend/api/invoices/edit.py | 16 +- backend/api/invoices/fetch.py | 45 +--- backend/api/invoices/recurring/fetch.py | 27 +-- backend/api/public/endpoints/Invoices/edit.py | 16 +- backend/api/public/endpoints/Invoices/list.py | 97 +++----- ...e_invoice_payment_status_invoice_status.py | 24 ++ backend/models.py | 15 +- backend/service/invoices/common/fetch.py | 85 +------ .../service/invoices/single/create/create.py | 2 - .../views/core/invoices/recurring/overview.py | 2 +- backend/webhooks/invoices/invoice_status.py | 51 ++++ docs/js/extra.js | 6 + docs/overrides/main.html | 6 +- docs/user-guide/invoices/index.md | 49 ++++ frontend/templates/base/_head.html | 1 + .../templates/pages/emails/dashboard.html | 2 - .../dashboard/+payment_status_badge.html | 4 +- .../pages/invoices/dashboard/_fetch_body.html | 81 ++----- .../pages/invoices/dashboard/manage.html | 16 +- .../recurring/dashboard/_fetch_body.html | 88 +------ .../recurring/dashboard/dashboard.html | 127 ++-------- .../invoices/single/dashboard/dashboard.html | 112 ++------- .../single/view/_banner/_invoice_status.html | 2 +- mkdocs.yml | 15 +- webpack.common.js | 4 + 27 files changed, 525 insertions(+), 597 deletions(-) create mode 100644 assets/scripts/tableify.js create mode 100644 backend/migrations/0064_remove_invoice_payment_status_invoice_status.py create mode 100644 backend/webhooks/invoices/invoice_status.py create mode 100644 docs/js/extra.js create mode 100644 docs/user-guide/invoices/index.md diff --git a/assets/scripts/init.js b/assets/scripts/init.js index d6464df46..afc049231 100644 --- a/assets/scripts/init.js +++ b/assets/scripts/init.js @@ -1,7 +1,6 @@ import Alpine from 'alpinejs' import $ from 'jquery' - window.Alpine = Alpine Alpine.start() diff --git a/assets/scripts/tableify.js b/assets/scripts/tableify.js new file mode 100644 index 000000000..559988b32 --- /dev/null +++ b/assets/scripts/tableify.js @@ -0,0 +1,228 @@ +window.Tableify = class Tableify { + constructor(selector) { + this.table = $(selector); + this.filters = {}; + this.currentSort = null; + this.sortDirection = 0; // 0 for no sort, 1 for ascending, -1 for descending + + this.initialize(); + } + + initialize() { + this.table.find("thead th").each((index, th) => { + const colName = $(th).attr("mft-col-name"); + const colFilters = $(th).attr("mft-filters"); + const filterType = $(th).attr("mft-filter-type"); + + // Add filter icon if filters are defined + if (colFilters || filterType === "searchable" || filterType === "searchable-amount" || filterType === "normal") { + const filterIcon = $(''); + const countBadge = $(''); + $(th).append(filterIcon).append(countBadge); + + $(th).addClass('cursor-pointer').on("click", () => { + this.toggleFilter(th, colName); + }); + } + }); + + // Initialize sorting buttons + $('[mft-sort-by]').each((index, button) => { + $(button).on("click", (e) => { + e.preventDefault(); + const colName = $(button).attr("mft-sort-by"); + this.handleSortButtonClick(colName); + }); + }); + } + + handleSortButtonClick(colName, parentId) { + // Determine the new sort direction + let newSortDirection; + + if (this.currentSort === colName) { + // If the same column is clicked again, toggle the direction + newSortDirection = this.sortDirection === 1 ? -1 : (this.sortDirection === -1 ? 0 : 1); + } else { + // New column is clicked, set to ascending + newSortDirection = 1; + } + + // Update the current sort column and direction + this.currentSort = newSortDirection === 0 ? null : colName; + this.sortDirection = newSortDirection; + + this.redraw(); // Redraw the table with updated sorting + } + + redraw() { + const rows = this.table.find("tbody tr"); + rows.show(); // Show all rows initially + + if (Object.keys(this.filters).length > 0) { + rows.each((index, row) => { + const isVisible = Object.keys(this.filters).every((colName) => { + const filterType = this.table.find(`th[mft-col-name="${colName}"]`).attr("mft-filter-type") || "normal"; + + // Get the index of the column + const colIndex = this.table.find(`th[mft-col-name="${colName}"]`).index(); + const cell = $(row).find(`td:eq(${colIndex})`); + + // Get the value to check against the filter + const cellText = cell.text(); + const cellValue = cell.attr("td-value") || cellText; // Use td-value if available + const parsedValue = parseFloat(cellValue); + + // Perform filtering logic based on filter type + if (filterType === "amount") { + const maxFilter = Math.max(...this.filters[colName].map(f => parseFloat(f))); + return parsedValue >= maxFilter; + } else if (filterType === "searchable") { + return cellText.toLowerCase().includes(this.filters[colName][0].toLowerCase()); + } else if (filterType === "searchable-amount") { + const inputValue = this.filters[colName][0]; + + // Handle both exact match and greater than + if (inputValue.endsWith('+')) { + const numericValue = parseFloat(inputValue.slice(0, -1)); // Remove '+' and parse + return parsedValue >= numericValue; // Include equal and greater + } else { + const numericValue = parseFloat(inputValue); // Exact number + return parsedValue === numericValue; // Exact match + } + } else { + return this.filters[colName].some(filterValue => cellValue.includes(filterValue)); + } + }); + + if (!isVisible) { + $(row).hide(); + } + }); + } + + // Handle sorting if a column is selected for sorting + if (this.currentSort) { + const sortedRows = rows.toArray().sort((a, b) => { + const valA = $(a).find(`td:eq(${this.table.find(`th[mft-col-name="${this.currentSort}"]`).index()})`).text(); + const valB = $(b).find(`td:eq(${this.table.find(`th[mft-col-name="${this.currentSort}"]`).index()})`).text(); + + const isAmount = this.table.find(`th[mft-col-name="${this.currentSort}"]`).attr("mft-filter-type") === "amount"; + + if (isAmount) { + return (parseFloat(valA) - parseFloat(valB)) * this.sortDirection; + } else { + return (valA < valB ? -1 : (valA > valB ? 1 : 0)) * this.sortDirection; + } + }); + + this.table.find("tbody").html(sortedRows); + } + + this.updateFilterCounts(); + } + + toggleFilter(element, colName) { + const colFilters = $(element).attr("mft-filters"); + const filterType = $(element).attr("mft-filter-type"); + + // Do nothing if there are no filters or search options defined + if (!colFilters && (filterType !== "searchable" && filterType !== "searchable-amount" && filterType !== "normal")) { + return; + } + + let dropdown = $(element).find('.filter-dropdown'); + if (dropdown.length === 0) { + dropdown = $(''); + + // Handle different filter types + if (filterType === "normal" && colFilters) { + const filters = colFilters.split(","); + filters.forEach((filter) => { + const checkbox = $(``); + checkbox.on("change", (e) => { + e.stopPropagation(); // Prevent closing on checkbox interaction + this.updateFilter(colName, filter, checkbox.find('input').is(":checked")); + }); + dropdown.append(checkbox); + }); + } else if (filterType === "searchable") { + // Create search input for searchable columns + const searchInput = $(``); + searchInput.on("input", (e) => { + e.stopPropagation(); // Prevent closing on typing + const searchValue = searchInput.val(); + if (searchValue) { + this.filters[colName] = [searchValue]; + } else { + delete this.filters[colName]; + } + this.redraw(); + }); + dropdown.append(searchInput); + } else if (filterType === "searchable-amount") { + // Create numeric input for searchable-amount columns + const amountInput = $(``); + amountInput.on("input", (e) => { + e.stopPropagation(); // Prevent closing on typing + const amountValue = amountInput.val(); + + if (amountValue) { + this.filters[colName] = [amountValue]; // Store as is + } else { + delete this.filters[colName]; // Remove if empty + } + + this.redraw(); + }); + dropdown.append(amountInput); + } + + $(element).append(dropdown); // Append the new dropdown to the column header + + // Prevent dropdown from closing when clicking inside + dropdown.on("click", (e) => { + e.stopPropagation(); // Prevent propagation of clicks within the dropdown + }); + } + + // Toggle dropdown visibility + dropdown.toggleClass('hidden'); + + // Close dropdown when clicking outside + const handleClickOutside = (e) => { + if (!$(e.target).closest(dropdown).length && !$(e.target).closest(element).length) { + dropdown.addClass('hidden'); + $(document).off('click', handleClickOutside); // Remove event listener when dropdown is closed + } + }; + + $(document).on('click', handleClickOutside); // Attach event listener to close dropdown when clicking outside + } + + updateFilter(colName, value, checked) { + if (!this.filters[colName]) this.filters[colName] = []; + + if (checked) { + if (!this.filters[colName].includes(value)) { + this.filters[colName].push(value); + } + } else { + this.filters[colName] = this.filters[colName].filter((val) => val !== value); + if (this.filters[colName].length === 0) delete this.filters[colName]; + } + + this.redraw(); + } + + updateFilterCounts() { + this.table.find("thead th").each((index, th) => { + const colName = $(th).attr("mft-col-name"); + const count = this.filters[colName] ? this.filters[colName].length : 0; + $(th).find('.filter-count').text(count).toggleClass('hidden', count === 0); + }); + } +} diff --git a/backend/api/invoices/edit.py b/backend/api/invoices/edit.py index f6cef5256..83aca9226 100644 --- a/backend/api/invoices/edit.py +++ b/backend/api/invoices/edit.py @@ -89,23 +89,15 @@ def change_status(request: HtmxHttpRequest, invoice_id: int, status: str) -> Htt if request.user.logged_in_as_team and request.user.logged_in_as_team != invoice.organization or request.user != invoice.user: return return_message(request, "You don't have permission to make changes to this invoice.") - if status not in ["paid", "overdue", "pending"]: - return return_message(request, "Invalid status. Please choose from: pending, paid, overdue") + if status not in ["paid", "draft", "pending"]: + return return_message(request, "Invalid status. Please choose from: pending, paid, draft") - if invoice.payment_status == status: + if invoice.status == status: return return_message(request, f"Invoice status is already {status}") - invoice.payment_status = status + invoice.status = status invoice.save() - dps = invoice.dynamic_payment_status - if (status == "overdue" and dps == "pending") or (status == "pending" and dps == "overdue"): - message = f""" - The invoice status was automatically changed from {status} to {dps} - as the invoice dates override the manual status. - """ - return return_message(request, message, success=False) - send_message(request, f"Invoice status been changed to {status}", success=True) return render(request, "pages/invoices/dashboard/_modify_payment_status.html", {"status": status, "invoice_id": invoice_id}) diff --git a/backend/api/invoices/fetch.py b/backend/api/invoices/fetch.py index 6e6e149cb..e15b56537 100644 --- a/backend/api/invoices/fetch.py +++ b/backend/api/invoices/fetch.py @@ -1,7 +1,4 @@ -from django.db.models import When, CharField, F -from django.db.models.expressions import Case, Value from django.shortcuts import render, redirect -from django.utils import timezone from django.views.decorators.http import require_http_methods from backend.decorators import web_require_scopes @@ -23,44 +20,12 @@ def fetch_all_invoices(request: HtmxHttpRequest): invoices = Invoice.objects.filter(user=request.user) # Get filter and sort parameters from the request - sort_by = request.GET.get("sort") - sort_direction = request.GET.get("sort_direction", "") - action_filter_type = request.GET.get("filter_type") - action_filter_by = request.GET.get("filter") + # sort_by = request.GET.get("sort") + # sort_direction = request.GET.get("sort_direction", "") + # action_filter_type = request.GET.get("filter_type") + # action_filter_by = request.GET.get("filter") - # Define previous filters as a dictionary - previous_filters = { - "payment_status": { - "paid": True if request.GET.get("payment_status_paid") else False, - "pending": True if request.GET.get("payment_status_pending") else False, - "overdue": True if request.GET.get("payment_status_overdue") else False, - }, - "amount": { - "20+": True if request.GET.get("amount_20+") else False, - "50+": True if request.GET.get("amount_50+") else False, - "100+": True if request.GET.get("amount_100+") else False, - }, - } - - context, invoices = get_context(invoices, sort_by, previous_filters, sort_direction, action_filter_type, action_filter_by) - - # check/update payment status to make sure it is correct before invoices are filtered and displayed - invoices.update( - payment_status=Case( - When( - date_due__lt=timezone.now().date(), - payment_status="pending", - then=Value("overdue"), - ), - When( - date_due__gt=timezone.now().date(), - payment_status="overdue", - then=Value("pending"), - ), - default=F("payment_status"), - output_field=CharField(), - ) - ) + context, invoices = get_context(invoices) # Render the HTMX response return render(request, "pages/invoices/dashboard/_fetch_body.html", context) diff --git a/backend/api/invoices/recurring/fetch.py b/backend/api/invoices/recurring/fetch.py index 35445bba5..8ad43e3f3 100644 --- a/backend/api/invoices/recurring/fetch.py +++ b/backend/api/invoices/recurring/fetch.py @@ -18,29 +18,12 @@ def fetch_all_recurring_invoices_endpoint(request: WebRequest): invoices = InvoiceRecurringProfile.filter_by_owner(owner=request.actor).filter(active=True) # Get filter and sort parameters from the request - sort_by = request.GET.get("sort") - sort_direction = request.GET.get("sort_direction", "") - action_filter_type = request.GET.get("filter_type") - action_filter_by = request.GET.get("filter") + # sort_by = request.GET.get("sort") + # sort_direction = request.GET.get("sort_direction", "") + # action_filter_type = request.GET.get("filter_type") + # action_filter_by = request.GET.get("filter") - # Define previous filters as a dictionary - previous_filters = { - "status": { - "ongoing": True if request.GET.get("status_ongoing") else False, - "paused": True if request.GET.get("status_paused") else False, - "cancelled": True if request.GET.get("status_cancelled") else False, - } - } - - context, _ = get_context(invoices, sort_by, previous_filters, sort_direction, action_filter_type, action_filter_by) - - previous_amount_filter = request.GET.get("amount_filter") - - amount_filter = previous_amount_filter if previous_amount_filter else action_filter_by if action_filter_type == "amount" else None - - if amount_filter: - context["invoices"] = context["invoices"].filter(amount__gte=amount_filter) - context["amount_filter"] = amount_filter + context, _ = get_context(invoices) paginator = Paginator(context["invoices"], 8) diff --git a/backend/api/public/endpoints/Invoices/edit.py b/backend/api/public/endpoints/Invoices/edit.py index 42abcf0c7..0772b4ebd 100644 --- a/backend/api/public/endpoints/Invoices/edit.py +++ b/backend/api/public/endpoints/Invoices/edit.py @@ -82,23 +82,15 @@ def change_status_endpoint(request, invoice_id: int, invoice_status: str): if request.user.logged_in_as_team and request.user.logged_in_as_team != invoice.organization or request.user != invoice.user: return Response({"error": "You do not have permission to edit this invoice"}, status=status.HTTP_403_FORBIDDEN) - if invoice_status not in ["paid", "overdue", "pending"]: - return Response({"error": "Invalid status. Please choose from: pending, paid, overdue"}, status=status.HTTP_400_BAD_REQUEST) + if invoice_status not in ["paid", "draft", "pending"]: + return Response({"error": "Invalid status. Please choose from: pending, paid, draft"}, status=status.HTTP_400_BAD_REQUEST) - if invoice.payment_status == invoice_status: + if invoice.status == invoice_status: return Response({"error": f"Invoice status is already {invoice_status}"}, status=status.HTTP_400_BAD_REQUEST) - invoice.payment_status = invoice_status + invoice.status = invoice_status invoice.save() - dps = invoice.dynamic_payment_status - if (invoice_status == "overdue" and dps == "pending") or (invoice_status == "pending" and dps == "overdue"): - message = f""" - The invoice status was automatically changed from {invoice_status} to {dps} - as the invoice dates override the manual status. - """ - return Response({"error": message}, status=status.HTTP_400_BAD_REQUEST) - return Response({"message": f"Invoice status been changed to {invoice_status}"}, status=status.HTTP_200_OK) diff --git a/backend/api/public/endpoints/Invoices/list.py b/backend/api/public/endpoints/Invoices/list.py index dabc7f33d..76b5fbca5 100644 --- a/backend/api/public/endpoints/Invoices/list.py +++ b/backend/api/public/endpoints/Invoices/list.py @@ -21,33 +21,33 @@ operation_id="list_invoices", manual_parameters=[ TEAM_PARAMETER, - openapi.Parameter( - "sort", - openapi.IN_QUERY, - description="Field you want to order by to. Sort options: 'date_due', 'id', 'payment_status'. Default by 'id'.", - type=openapi.TYPE_STRING, - ), - openapi.Parameter( - "sort_direction", - openapi.IN_QUERY, - description="Order by descending or ascending. 'False' for descending and 'True' for ascending. Default is ascending.", - type=openapi.TYPE_STRING, - ), - openapi.Parameter( - "filter_type", - openapi.IN_QUERY, - description="Select filter type by which results will be filtered. Filter types are 'payment_status' and " - "'amount'. By default there is no filter types applied.", - type=openapi.TYPE_STRING, - ), - openapi.Parameter( - "filter", - openapi.IN_QUERY, - description="Select filter by which results will be filtered. Filters for 'payment_status' are 'paid', " - "'pending', 'overdue' and for 'amount' are '20+', '50+', '100+'. By default there is no " - "filter applied.", - type=openapi.TYPE_STRING, - ), + # openapi.Parameter( + # "sort", + # openapi.IN_QUERY, + # description="Field you want to order by to. Sort options: 'date_due', 'id', 'status'. Default by 'id'.", + # type=openapi.TYPE_STRING, + # ), + # openapi.Parameter( + # "sort_direction", + # openapi.IN_QUERY, + # description="Order by descending or ascending. 'False' for descending and 'True' for ascending. Default is ascending.", + # type=openapi.TYPE_STRING, + # ), + # openapi.Parameter( + # "filter_type", + # openapi.IN_QUERY, + # description="Select filter type by which results will be filtered. Filter types are 'status' and " + # "'amount'. By default there is no filter types applied.", + # type=openapi.TYPE_STRING, + # ), + # openapi.Parameter( + # "filter", + # openapi.IN_QUERY, + # description="Select filter by which results will be filtered. Filters for 'status' are 'paid', " + # "'pending', 'overdue', 'draft' and for 'amount' are '20+', '50+', '100+'. By default there is no " + # "filter applied.", + # type=openapi.TYPE_STRING, + # ), ], responses={ 200: openapi.Response( @@ -70,45 +70,14 @@ def list_invoices_endpoint(request: APIRequest) -> Response: else: invoices = Invoice.objects.filter(user=request.user) - sort_by = request.query_params.get("sort") - sort_direction = request.query_params.get("sort_direction", "") - action_filter_type = request.query_params.get("filter_type") - action_filter_by = request.query_params.get("filter") + # sort_by = request.query_params.get("sort") + # sort_direction = request.query_params.get("sort_direction", "") + # action_filter_type = request.query_params.get("filter_type") + # action_filter_by = request.query_params.get("filter") - previous_filters = { - "payment_status": { - "paid": False, - "pending": False, - "overdue": False, - }, - "amount": { - "20+": False, - "50+": False, - "100+": False, - }, - } + # todo: add back sort + filters on backend for API - _, invoices = get_context( - invoices, sort_by, previous_filters, sort_direction, action_filter_type, action_filter_by - ) # type: ignore[assignment] - - # check/update payment status to make sure it is correct before invoices are filtered and displayed - invoices.update( - payment_status=Case( - When( - date_due__lt=timezone.now().date(), - payment_status="pending", - then=Value("overdue"), - ), - When( - date_due__gt=timezone.now().date(), - payment_status="overdue", - then=Value("pending"), - ), - default=F("payment_status"), - output_field=CharField(), - ) - ) + _, invoices = get_context(invoices) # type: ignore[assignment] serializer = InvoiceSerializer(invoices, many=True) diff --git a/backend/migrations/0064_remove_invoice_payment_status_invoice_status.py b/backend/migrations/0064_remove_invoice_payment_status_invoice_status.py new file mode 100644 index 000000000..d0049a0ed --- /dev/null +++ b/backend/migrations/0064_remove_invoice_payment_status_invoice_status.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1 on 2024-09-28 19:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("backend", "0063_defaultvalues_email_template_recurring_invoices_invoice_cancelled_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="invoice", + name="payment_status", + ), + migrations.AddField( + model_name="invoice", + name="status", + field=models.CharField( + choices=[("draft", "Draft"), ("pending", "Pending"), ("paid", "Paid")], default="pending", max_length=10 + ), + ), + ] diff --git a/backend/models.py b/backend/models.py index 60d7117fb..34240f06a 100644 --- a/backend/models.py +++ b/backend/models.py @@ -583,14 +583,15 @@ class Invoice(InvoiceBase): # objects = InvoiceManager() STATUS_CHOICES = ( + ("draft", "Draft"), + # ("ready", "Ready"), ("pending", "Pending"), ("paid", "Paid"), - ("overdue", "Overdue"), ) invoice_id = models.IntegerField(unique=True, blank=True, null=True) # todo: add date_due = models.DateField() - payment_status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="pending") + status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="draft") invoice_recurring_profile = models.ForeignKey( "InvoiceRecurringProfile", related_name="generated_invoices", on_delete=models.SET_NULL, blank=True, null=True ) @@ -607,11 +608,15 @@ def __str__(self): 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": + def dynamic_status(self): + if self.status == "pending" and self.is_overdue: return "overdue" else: - return self.payment_status + return self.status + + @property + def is_overdue(self): + return self.date_due and timezone.now().date() > self.date_due @property def get_to_details(self) -> tuple[str, dict[str, str | None]] | tuple[str, Client]: diff --git a/backend/service/invoices/common/fetch.py b/backend/service/invoices/common/fetch.py index bad872b44..decbc7d6f 100644 --- a/backend/service/invoices/common/fetch.py +++ b/backend/service/invoices/common/fetch.py @@ -4,48 +4,11 @@ from backend.models import Invoice, InvoiceItem -def build_filter_condition(filter_type, filter_by): - if "+" in filter_by: - numeric_part = float(filter_by.split("+")[0]) - return {f"{filter_type}__gte": numeric_part} - else: - return {f"{filter_type}": filter_by} - - def should_add_condition(was_previous_selection, has_just_been_selected): return (was_previous_selection and not has_just_been_selected) or (not was_previous_selection and has_just_been_selected) -def filter_conditions(or_conditions, previous_filters, action_filter_by, action_filter_type, context): - for filter_type, filter_by_list in previous_filters.items(): - or_conditions_filter = Q() # Initialize OR conditions for each filter type - for filter_by, status in filter_by_list.items(): - # Determine if the filter was selected in the previous request - was_previous_selection = bool(status) - # Determine if the filter is selected in the current request - has_just_been_selected = action_filter_by == filter_by and action_filter_type == filter_type - - # Check if the filter status has changed - if should_add_condition(was_previous_selection, has_just_been_selected): - # Construct filter condition dynamically based on filter_type - filter_condition = build_filter_condition(filter_type, filter_by) - or_conditions_filter |= Q(**filter_condition) - context["selected_filters"].append(filter_by) - - # Combine OR conditions for each filter type with AND - or_conditions &= or_conditions_filter - - return or_conditions, context - - -def get_context( - invoices: QuerySet, - sort_by: str | None, - previous_filters: dict, - sort_direction: str = "True", - action_filter_type: str | None = None, - action_filter_by: str | None = None, -) -> tuple[dict, QuerySet[Invoice]]: +def get_context(invoices: QuerySet) -> tuple[dict, QuerySet[Invoice]]: context: dict = {} invoices = ( @@ -74,42 +37,16 @@ def get_context( .distinct() # just an extra precaution ) - # Initialize context variables - context["selected_filters"] = [] - context["all_filters"] = {item: [i for i, _ in dictio.items()] for item, dictio in previous_filters.items()} - - # Initialize OR conditions for filters using Q objects - or_conditions = Q() - - if action_filter_by or action_filter_type: - or_conditions, context = filter_conditions(or_conditions, previous_filters, action_filter_by, action_filter_type, context) - - invoices = invoices.filter(or_conditions) - - if action_filter_type == "id": - invoices = invoices.filter(id=action_filter_by) - elif action_filter_type == "recurring_profile_id": - invoices = invoices.filter(invoice_recurring_profile=action_filter_by) - - # Validate and sanitize the sort_by parameter - all_sort_options = ["end_date", "id", "status"] - context["all_sort_options"] = all_sort_options - - # Apply sorting to the invoices queryset - if sort_by not in all_sort_options: - context["sort"] = "id" - elif sort_by in all_sort_options: - # True is for reverse order - # first time set direction is none - if sort_direction is not None and sort_direction.lower() == "true" or sort_direction == "": - context["sort"] = f"-{sort_by}" - context["sort_direction"] = False - invoices = invoices.order_by(f"-{sort_by}") - else: - # sort_direction is False - context["sort"] = sort_by - context["sort_direction"] = True - invoices = invoices.order_by(sort_by) + if invoices.model is Invoice: + invoices = invoices.annotate( + filterable_dynamic_status=Case( + When(status="draft", then=Value("draft")), + When(status="pending", date_due__gt=timezone.now(), then=Value("pending")), + When(status="pending", date_due__lt=timezone.now(), then=Value("overdue")), + When(status="paid", then=Value("paid")), + output_field=CharField(), + ) + ) context["invoices"] = invoices diff --git a/backend/service/invoices/single/create/create.py b/backend/service/invoices/single/create/create.py index 1b5c03697..617be9980 100644 --- a/backend/service/invoices/single/create/create.py +++ b/backend/service/invoices/single/create/create.py @@ -86,8 +86,6 @@ def save_invoice(request: WebRequest, invoice_items): save_invoice_common(request, invoice_items, invoice) - invoice.payment_status = invoice.dynamic_payment_status - try: invoice.full_clean() except ValidationError as validation_errors: diff --git a/backend/views/core/invoices/recurring/overview.py b/backend/views/core/invoices/recurring/overview.py index f0b5a2b01..54fa1eef3 100644 --- a/backend/views/core/invoices/recurring/overview.py +++ b/backend/views/core/invoices/recurring/overview.py @@ -36,7 +36,7 @@ def manage_recurring_invoice_profile_endpoint(request: WebRequest, invoice_profi context["total_paid"] = 0 for invoice in invoice_profile.generated_invoices.all(): - if invoice.payment_status == "paid": + if invoice.status == "paid": context["total_paid"] += 1 context["total_amt"] += invoice.get_total_price() diff --git a/backend/webhooks/invoices/invoice_status.py b/backend/webhooks/invoices/invoice_status.py new file mode 100644 index 000000000..63da8ad19 --- /dev/null +++ b/backend/webhooks/invoices/invoice_status.py @@ -0,0 +1,51 @@ +from datetime import datetime, timedelta + +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST +from login_required import login_not_required + +from backend.models import InvoiceRecurringProfile, Invoice, DefaultValues, AuditLog +from backend.service.invoices.recurring.generation.next_invoice import safe_generate_next_invoice_service +from backend.service.invoices.recurring.webhooks.webhook_apikey_auth import authenticate_api_key + +import logging + +from backend.types.requests import WebRequest +from settings.settings import AWS_TAGS_APP_NAME + +logger = logging.getLogger(__name__) + + +@require_POST +@csrf_exempt +@login_not_required +def handle_invoice_now_webhook_endpoint(request: WebRequest): + + invoice_profile_id = request.POST.get("invoice_profile_id", "") + + logger.info("Received Scheduled Invoice. Now authenticating...") + api_auth_response = authenticate_api_key(request) + + if api_auth_response.failed: + logger.info(f"Webhook auth failed: {api_auth_response.error}") + return JsonResponse({"message": api_auth_response.error, "success": False}, status=api_auth_response.status_code or 400) + + try: + invoice_recurring_profile: InvoiceRecurringProfile = InvoiceRecurringProfile.objects.get(pk=invoice_profile_id, active=True) + except InvoiceRecurringProfile.DoesNotExist: + logger.error(f"Invoice recurring profile was not found (#{invoice_profile_id}). ERROR!") + return JsonResponse({"message": "Invoice recurring profile not found", "success": False}, status=404) + + logger.info("Invoice recurring profile found. Now processing...") + + DATE_TODAY = datetime.now().date() + + svc_resp = safe_generate_next_invoice_service(invoice_recurring_profile=invoice_recurring_profile, issue_date=DATE_TODAY) + + if svc_resp.success: + logger.info("Successfully generated next invoice") + return JsonResponse({"message": "Invoice generated", "success": True}) + else: + logger.info(svc_resp.error) + return JsonResponse({"message": svc_resp.error, "success": False}, status=400) diff --git a/docs/js/extra.js b/docs/js/extra.js new file mode 100644 index 000000000..07fba6241 --- /dev/null +++ b/docs/js/extra.js @@ -0,0 +1,6 @@ +document.addEventListener("DOMContentLoaded", function () { + const viewSourceButton = document.querySelector('a[title="View source of this page"]'); + if (viewSourceButton) { + viewSourceButton.style.display = 'none'; + } +}); diff --git a/docs/overrides/main.html b/docs/overrides/main.html index 90210d5c6..ce11458ae 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -1,4 +1,4 @@ {% extends "base.html" %} -{% block announce %} - New Announcement: License Change to AGPL v3 -{% endblock %} +{#{% block announce %}#} +{# New Announcement: License Change to AGPL v3#} +{#{% endblock %}#} diff --git a/docs/user-guide/invoices/index.md b/docs/user-guide/invoices/index.md new file mode 100644 index 000000000..e31816ae6 --- /dev/null +++ b/docs/user-guide/invoices/index.md @@ -0,0 +1,49 @@ +# Invoices + +Invoices allow you to bill customers for tasks or products. Customers can be sent an "invoice link" which allows them to view +the invoice via the dashboard, print out the invoice and pay the invoice. + +## View an Invoice + +You can find an invoice from the "`Invoices`" -> "`Single`" tab, where every invoice under your [Logged in Profile](#) will be +shown. Use the filters above each column to narrow down the list. + +You can view an overview of the invoice by either clicking anywhere in the row, or the "three dots" -> "overview". + +Here you can view things like the invoice status, ID, discounts, preview, and edit the invoice details. + +## Invoice Statuses + +Each invoice has a status: `draft`, `pending` or `paid`. + +- **draft**: the invoice isn't yet finalised, you can easily make changes and get the invoice ready without the user +interacting or viewing details. +- **pending**: the invoice is ready for customer viewing and awaiting payment +- **overdue**: the invoice is still `pending`, however the due date has expired. You and the user can view the invoice as now +"overdue" - it's up to your business whether you add late fees. +- **paid**: the invoice has been fully (or partially) paid by the user and doesn't need modification + + +## Invoices vs Invoice Profiles + +**Invoices** are invoice documents sent to customers to be paid. They have a status, products, total price, etc. **Invoice +Profiles** are sets of invoices that are often automatically created on a recurring basis, to be paid regularly. In an invoice +profile a normal **Invoice** is created every period. The profile just defines how often they are created, and the default +values attached to it. + +# Invoice Profiles + +As stated above, invoice profiles hold individual invoices and can be set to create a new invoice every period. + +## Recurring Frequencies + +- Weekly - choose a day of week, e.g. "mondays" +- Monthly - choose a day of month, e.g. "15th" +- Yearly - choose a day of month, e.g. "15th" and choose a month of year e.g. "january" + +A single Invoice will be created every (period defined) and set to draft, you will be emailed automatically. + +Invoices are created at `7am UTC` (`8am BST`) of each period, this is currently not customisable. Please contact us at +[enquiry@myfinances.cloud](mailto:enquiry@myfinances.cloud) if you require the flexibility. + +The `end date` is when the automatic invoice creation will finish. diff --git a/frontend/templates/base/_head.html b/frontend/templates/base/_head.html index 9eb4a93d7..2e634e3dd 100644 --- a/frontend/templates/base/_head.html +++ b/frontend/templates/base/_head.html @@ -53,6 +53,7 @@ {# #} {# {% render_bundle 'all' 'js' %}#} {% render_bundle 'init' 'js' %} + {% render_bundle 'tableify' 'js' %} {% render_bundle 'htmx' 'js' %} {% render_bundle 'font_awesome' 'js' %} diff --git a/frontend/templates/pages/emails/dashboard.html b/frontend/templates/pages/emails/dashboard.html index 73f23eefb..588885466 100644 --- a/frontend/templates/pages/emails/dashboard.html +++ b/frontend/templates/pages/emails/dashboard.html @@ -1,7 +1,5 @@ {% extends base|default:"base/base.html" %} {% block content %} -
-
+
  • -
  • - -
  • @@ -173,48 +173,3 @@ No Invoices Found {% endfor %} - {% for option in all_sort_options %} -
    - {% if sort == option or sort == "-"|add:option %}{% endif %} - {% if option == "payment_status" %} - Payment Status - {% elif option == "date_due" %} - Date - {% elif option == "amount" %} - Amount - {% else %} - ID - {% endif %} -
    - {% endfor %} - {% for filter_type, inner_filters in all_filters.items %} - {% for filter in inner_filters %} -
    - {% if filter in selected_filters %}{% endif %} - {{ filter | title }} -
    - {% endfor %} - {% endfor %} -
    - - - - - - -
    -
    - -
    diff --git a/frontend/templates/pages/invoices/dashboard/manage.html b/frontend/templates/pages/invoices/dashboard/manage.html index 6ba3ff547..e203edd8d 100644 --- a/frontend/templates/pages/invoices/dashboard/manage.html +++ b/frontend/templates/pages/invoices/dashboard/manage.html @@ -51,7 +51,7 @@

    Invoice #{{ invoice.id }}

    - {% component "pages:invoices:dashboard:payment_status_badge" status=invoice.payment_status inv_id=invoice_id design="default" %} + {% component "pages:invoices:dashboard:payment_status_badge" status=invoice.dynamic_status inv_id=invoice_id design="default" %}
    @@ -112,7 +111,6 @@ hx-swap="outerHTML" hx-target="#table_body" hx-indicator="" - hx-include='#filter_list_storage, #sort_direction_storage, [name="sort"]' hx-get="{% url "api:invoices:recurring:fetch" %}?page={{ page.next_page_number }}"> » @@ -140,72 +138,6 @@ {% endif %}
    {% endfor %} -{% for option in all_sort_options %} -
    - {% if sort == option or sort == "-"|add:option %}{% endif %} - {% if option == "status" %} - Status - {% elif option == "end_date" %} - Date - {% elif option == "amount" %} - Amount - {% else %} - ID - {% endif %} -
    -{% endfor %} -{% for filter_type, inner_filters in all_filters.items %} - {% for filter in inner_filters %} - {% comment %} - This will set the checkmarks for each selected filter - {% endcomment %} -
    - {% if filter in selected_filters %} - - {% else %} - - {% endif %} -
    - {% comment %} - This will set the little in brackets (x) number for how many filters are applied - {% endcomment %} -
    - {% with FILTER_COUNT=inner_filters|common_children_filter:selected_filters|length %} - {{ filter_type | title }} - {% if FILTER_COUNT %}({{ FILTER_COUNT }}){% endif %} - {% endwith %} -
    - {% endfor %} -{% endfor %} -
    - - - - - - -
    -
    - -
    -
    - -
    -
    - -
    diff --git a/frontend/templates/pages/invoices/recurring/dashboard/dashboard.html b/frontend/templates/pages/invoices/recurring/dashboard/dashboard.html index 1ad4216b6..8fbfe5f12 100644 --- a/frontend/templates/pages/invoices/recurring/dashboard/dashboard.html +++ b/frontend/templates/pages/invoices/recurring/dashboard/dashboard.html @@ -1,80 +1,6 @@
    - @@ -130,25 +50,19 @@

    Recurring Invoices

    -
    -
    -
    -
    -
    -
    -
    - +
    +
    - - - - - - + + + + + + @@ -159,6 +73,7 @@

    Recurring Invoices

    {% include 'components/table/skeleton_rows.html' with rows=3 cols=6 %}
    Profile IDEnd DateClient NameAmountStatus
    Profile IDEnd DateClient NameAmountStatus Actions
    +
    {% component "messages_list" %} diff --git a/frontend/templates/pages/invoices/single/dashboard/dashboard.html b/frontend/templates/pages/invoices/single/dashboard/dashboard.html index 7361b774d..8052dd52b 100644 --- a/frontend/templates/pages/invoices/single/dashboard/dashboard.html +++ b/frontend/templates/pages/invoices/single/dashboard/dashboard.html @@ -1,9 +1,6 @@
    - @@ -54,84 +44,17 @@

    Invoices

    -
    -
    -
    -
    - +
    - - - - - - + + + + + + @@ -144,4 +67,5 @@
    Invoice IDDue DateClient Name - - - -
    Invoice IDDue DateClient NameAmountStatus Actions
    + {% component "messages_list" %} diff --git a/frontend/templates/pages/invoices/single/view/_banner/_invoice_status.html b/frontend/templates/pages/invoices/single/view/_banner/_invoice_status.html index 81ddb7602..3c2d5a819 100644 --- a/frontend/templates/pages/invoices/single/view/_banner/_invoice_status.html +++ b/frontend/templates/pages/invoices/single/view/_banner/_invoice_status.html @@ -1,6 +1,6 @@

    - {% with ps=invoice.payment_status %} + {% with ps=invoice.status %} {% if ps == "paid" %} diff --git a/mkdocs.yml b/mkdocs.yml index 67d9fc826..d099819c0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -75,6 +75,11 @@ site_description: Documentation for MyFinances site_author: treyww nav: + - User Guide: + - user-guide/index.md + - Pricing: user-guide/pricing/index.md + - Email Templates: user-guide/emails/templates/index.md + - Invoices: user-guide/invoices/index.md - Developer Guide: - getting-started/index.md - Installation: getting-started/installation.md @@ -97,8 +102,6 @@ nav: # - Private Media Files: getting-started/settings/AWS/private-media.md - Common issues / debugging: - Poetry: debugging/python/poetry.md - # - User Guide: - # - user-guide/index.md - Contributing: - contributing/index.md - Setup project: contributing/setup.md @@ -111,11 +114,6 @@ nav: - Motivation: contributing/motivation.md - Development Progression: contributing/development-progression.md - Taking on a lead position: contributing/lead-position.md - - User Guide: - - user-guide/index.md - - Pricing: user-guide/pricing/index.md - - Email Templates: - - Home: user-guide/emails/templates/index.md - Changelog: - changelog/index.md @@ -127,3 +125,6 @@ extra: status: new: Recently added deprecated: Deprecated + +extra_javascript: + - 'js/extra.js' diff --git a/webpack.common.js b/webpack.common.js index 8fbe7d3c8..868b6d080 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -9,6 +9,10 @@ module.exports = { import: './assets/scripts/htmx.js', dependOn: 'init' }, + tableify: { + import: './assets/scripts/tableify.js', + dependOn: 'init' + }, receipt_downloads: { import: './assets/scripts/receipt_downloads.js' },