Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Invoice Form and Payments #387

Merged
merged 21 commits into from
Sep 18, 2024
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions commcare_connect/opportunity/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
Opportunity,
OpportunityAccess,
OpportunityVerificationFlags,
PaymentInvoice,
PaymentUnit,
VisitValidationStatus,
)
Expand Down Expand Up @@ -752,3 +753,35 @@ def __init__(self, *args, **kwargs):
if self.instance:
self.fields["form_submission_start"].initial = self.instance.form_submission_start
self.fields["form_submission_end"].initial = self.instance.form_submission_end


class PaymentInvoiceForm(forms.ModelForm):
class Meta:
model = PaymentInvoice
fields = ("amount", "date", "invoice_number")
widgets = {"date": forms.DateInput(attrs={"type": "date", "class": "form-control"})}

def __init__(self, *args, **kwargs):
self.opportunity = kwargs.pop("opportunity")
super().__init__(*args, **kwargs)

self.helper = FormHelper(self)
self.helper.layout = Layout(
Row(Field("amount")),
Row(Field("date")),
Row(Field("invoice_number")),
)
self.helper.form_tag = False

def clean_invoice_number(self):
invoice_number = self.cleaned_data["invoice_number"]
if PaymentInvoice.objects.filter(opportunity=self.opportunity, invoice_number=invoice_number).exists():
raise ValidationError(f'Invoice "{invoice_number}" already exists', code="invoice_number_reused")
return invoice_number

def save(self, commit=True):
instance = super().save(commit=False)
instance.opportunity = self.opportunity
if commit:
instance.save()
return instance
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Generated by Django 4.2.5 on 2024-09-05 08:08

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):
dependencies = [
("opportunity", "0056_payment_organization"),
]

operations = [
migrations.CreateModel(
name="PaymentInvoice",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("amount", models.PositiveIntegerField()),
("date", models.DateField()),
("invoice_number", models.CharField(max_length=50)),
(
"opportunity",
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="opportunity.opportunity"),
),
],
options={
"unique_together": {("opportunity", "invoice_number")},
},
),
migrations.AddField(
model_name="payment",
name="invoice",
field=models.OneToOneField(
blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, to="opportunity.paymentinvoice"
),
),
]
11 changes: 11 additions & 0 deletions commcare_connect/opportunity/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,16 @@ class VisitValidationStatus(models.TextChoices):
trial = "trial", gettext("Trial")


class PaymentInvoice(models.Model):
opportunity = models.ForeignKey(Opportunity, on_delete=models.CASCADE)
amount = models.PositiveIntegerField()
date = models.DateField()
invoice_number = models.CharField(max_length=50)

class Meta:
unique_together = ("opportunity", "invoice_number")


class Payment(models.Model):
amount = models.PositiveIntegerField()
date_paid = models.DateTimeField(auto_now_add=True)
Expand All @@ -403,6 +413,7 @@ class Payment(models.Model):
confirmation_date = models.DateTimeField(null=True)
# This is used to indicate Payments made to Network Manager organizations
organization = models.ForeignKey(Organization, on_delete=models.DO_NOTHING, null=True, blank=True)
invoice = models.OneToOneField(PaymentInvoice, on_delete=models.DO_NOTHING, null=True, blank=True)


class CompletedWorkStatus(models.TextChoices):
Expand Down
30 changes: 30 additions & 0 deletions commcare_connect/opportunity/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
CompletedWork,
OpportunityAccess,
Payment,
PaymentInvoice,
PaymentUnit,
UserInvite,
UserInviteStatus,
Expand Down Expand Up @@ -410,6 +411,35 @@ class Meta:
orderable = False


class PaymentInvoiceTable(tables.Table):
pk = columns.CheckBoxColumn(
accessor="pk",
verbose_name="",
attrs={
"input": {"x-model": "selected"},
"th__input": {"@click": "toggleSelectAll()", "x-bind:checked": "selectAll"},
},
)
payment_status = columns.Column(verbose_name="Payment Status", accessor="payment", empty_values=())
payment_date = columns.Column(verbose_name="Payment Date", accessor="payment", empty_values=(None))

class Meta:
model = PaymentInvoice
orderable = False
fields = ("pk", "amount", "date", "invoice_number")
empty_text = "No Payment Invoices"

def render_payment_status(self, value):
if value is not None:
return "Paid"
return "Pending"

def render_payment_date(self, value):
if value is not None:
return value.date_paid
return


def popup_html(value, popup_title, popup_direction="top", popup_class="", popup_attributes=""):
return format_html(
"<span class='{}' data-bs-toggle='tooltip' data-bs-placement='{}' data-bs-title='{}' {}>{}</span>",
Expand Down
5 changes: 5 additions & 0 deletions commcare_connect/opportunity/urls.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.urls import path

from commcare_connect.opportunity import views
from commcare_connect.opportunity.views import (
OpportunityCompletedWorkTable,
OpportunityCreate,
Expand Down Expand Up @@ -107,4 +108,8 @@
),
path("<int:opp_id>/user_visit_review", user_visit_review, name="user_visit_review"),
path("<int:pk>/payment_report", payment_report, name="payment_report"),
path("<int:pk>/invoice/", views.invoice_list, name="invoice_list"),
path("<int:pk>/invoice_table/", views.PaymentInvoiceTableView.as_view(), name="invoice_table"),
path("<int:pk>/invoice/create/", views.invoice_create, name="invoice_create"),
path("<int:pk>/invoice/approve/", views.invoice_approve, name="invoice_approve"),
]
70 changes: 70 additions & 0 deletions commcare_connect/opportunity/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from functools import reduce

from celery.result import AsyncResult
from crispy_forms.utils import render_crispy_form
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
Expand Down Expand Up @@ -33,6 +34,7 @@
OpportunityInitForm,
OpportunityVerificationFlagsConfigForm,
PaymentExportForm,
PaymentInvoiceForm,
PaymentUnitForm,
SendMessageMobileUsersForm,
VisitExportForm,
Expand All @@ -53,6 +55,7 @@
OpportunityClaimLimit,
OpportunityVerificationFlags,
Payment,
PaymentInvoice,
PaymentUnit,
UserVisit,
VisitValidationStatus,
Expand All @@ -62,6 +65,7 @@
DeliverStatusTable,
LearnStatusTable,
OpportunityPaymentTable,
PaymentInvoiceTable,
PaymentReportTable,
PaymentUnitTable,
SuspendedUsersTable,
Expand Down Expand Up @@ -1097,3 +1101,69 @@ def payment_report(request, org_slug, pk):
total_nm_payment_accrued=total_nm_payment_accrued,
),
)


class PaymentInvoiceTableView(OrganizationUserMixin, SingleTableView):
model = PaymentInvoice
paginate_by = 25
table_class = PaymentInvoiceTable
template_name = "tables/single_table.html"

def get_table_kwargs(self):
kwargs = super().get_table_kwargs()
if self.request.org_membership != None and not self.request.org_membership.is_program_manager: # noqa: E711
kwargs["exclude"] = ("pk",)
return kwargs

def get_queryset(self):
opportunity_id = self.kwargs["pk"]
opportunity = get_opportunity_or_404(org_slug=self.request.org.slug, pk=opportunity_id)
filter_kwargs = dict(opportunity=opportunity)
table_filter = self.request.GET.get("filter")
if table_filter is not None and table_filter in ["paid", "pending"]:
filter_kwargs["payment__isnull"] = table_filter == "pending"
return PaymentInvoice.objects.filter(**filter_kwargs).order_by("date")


@org_member_required
def invoice_list(request, org_slug, pk):
opportunity = get_opportunity_or_404(pk, org_slug)
if not opportunity.managed:
return redirect("opportunity:detail", org_slug, pk)
form = PaymentInvoiceForm(opportunity=opportunity)
return render(
request,
"opportunity/invoice_list.html",
context=dict(opportunity=opportunity, form=form),
)


@org_member_required
def invoice_create(request, org_slug, pk):
opportunity = get_opportunity_or_404(pk, org_slug)
if not opportunity.managed or request.org_membership.is_program_manager:
return redirect("opportunity:detail", org_slug, pk)
form = PaymentInvoiceForm(data=request.POST or None, opportunity=opportunity)
if request.POST and form.is_valid():
form.save()
form = PaymentInvoiceForm(opportunity=opportunity)
return HttpResponse(render_crispy_form(form), headers={"HX-Trigger": "newInvoice"})
return HttpResponse(render_crispy_form(form))


@org_member_required
@require_POST
def invoice_approve(request, org_slug, pk):
opportunity = get_opportunity_or_404(pk, org_slug)
if not opportunity.managed or not request.org_membership.is_program_manager:
return redirect("opportunity:detail", org_slug, pk)
invoice_ids = request.POST.getlist("pk")
invoices = PaymentInvoice.objects.filter(opportunity=opportunity, pk__in=invoice_ids, payment__isnull=True)
for invoice in invoices:
payment = Payment(
amount=invoice.amount,
organization=opportunity.organization,
invoice=invoice,
)
payment.save()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't this create duplicates on multiple calls?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a filter to the query to only create payment for invoices that don't have a payment.

return HttpResponse(headers={"HX-Trigger": "newInvoice"})
4 changes: 4 additions & 0 deletions commcare_connect/organization/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ def is_admin(self):
def is_viewer(self):
return self.role == self.Role.VIEWER

@property
def is_program_manager(self):
return self.organization.program_manager and self.is_admin

class Meta:
db_table = "organization_membership"
unique_together = ("user", "organization")
Expand Down
109 changes: 109 additions & 0 deletions commcare_connect/templates/opportunity/invoice_list.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
{% extends "opportunity/base.html" %}
{% load django_tables2 %}
{% load static %}
{% load crispy_forms_tags %}

{% block title %}{{ request.org }} - Invoices{% endblock title %}

{% block breadcrumbs_inner %}
{{ block.super }}
<li class="breadcrumb-item">
<a href="{% url 'opportunity:detail' org_slug=request.org.slug pk=opportunity.pk %}">
{{ opportunity.name }}
</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Invoices</li>
{% endblock %}

{% block content %}
<h2>Invoices</h2>
<hr />
{% if not request.org_membership.is_program_manager %}
<button type="button" class="btn btn-primary mb-2" data-bs-toggle="modal" data-bs-target="#invoiceModal">
{% translate Add New Invoice %}
</button>
{% endif %}

<form hx-get="{% url "opportunity:invoice_table" org_slug=request.org.slug pk=opportunity.pk %}"
hx-trigger="load, change, newInvoice from:body"
hx-target="#invoiceTable">
<div class="input-group mb-3" x-data="{ filter: new URLSearchParams(location.search).get('filter')}">
<label class="input-group-text" for="filterSelect">Filter</label>
<select class="form-select" id="filterSelect" name="filter" x-model="filter">
<option selected value="">All</option>
<option value="paid">Paid</option>
<option value="pending">Pending</option>
</select>
</div>
</form>

{% if request.org_membership.is_program_manager %}
<form x-data="{
selectAll: false,
selected: [],
toggleSelectAll() {
this.selectAll = !this.selectAll;
const checkboxes = document.querySelectorAll('input[name=pk]');
const allSelected = [];
checkboxes.forEach(el => {
allSelected.push(el.value);
this.selected = this.selectAll ? allSelected : [];
});
}
}"
hx-post="{% url "opportunity:invoice_approve" org_slug=request.org.slug pk=opportunity.pk %}"
hx-swap="none"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why does this need a csrf token? shouldn't the django form handle this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Django was not able to find csrf token on forms submitted using 'hx-post'. This include the csrf token as a request header to provide the csrf token.

{% endif %}

<div id="invoiceTable">
{% include "tables/table_placeholder.html" with num_cols=4 %}
</div>

{% if request.org_membership.is_program_manager %}
<button type="submit" class="btn btn-primary mt-2">{% translate "Pay" %}</button>
</form>
{% endif %}
{% endblock content %}

{% if not request.org_membership.is_program_manager %}
{% block modal %}
<div class="modal fade" id="invoiceModal" tabindex="-1" role="dialog" aria-labelledby="invoiceModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="invoiceModalLabel">{% translate "Create Invoice" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" id="invoiceModalClose"></button>
</div>
<form hx-post="{% url "opportunity:invoice_create" org_slug=request.org.slug pk=opportunity.pk %}"
hx-trigger="submit"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-target="#formFields"
hx-on::after-request="this.reset()">
<div class="modal-body">
<div id="formFields">{% crispy form %}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Close" %}</button>
<button type="submit" class="btn btn-primary">{% translate "Save changes" %}</button>
</div>
</form>
</div>
</div>
</div>
{% endblock modal %}
{% endif %}

{% block inline_javascript %}
<script>
window.addEventListener('DOMContentLoaded', () => {
const filter = document.querySelector("#filterSelect");
filter.addEventListener("change", (event) => {
const url = new URL(window.location);
url.searchParams.set("filter", event.target.value)
history.pushState(null, '', url);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this just to share selections via URL?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, this updates the URL to have the filter selected by user.

});
document.addEventListener('newInvoice', () => document.getElementById('invoiceModalClose').click());
});
</script>
{% endblock inline_javascript %}
Loading