Commit 39e3f90a authored by Patrick Kimber's avatar Patrick Kimber

Merge branch '4044-exception-list' into 'master'

Invoice issues

See merge request !16
parents dea6ff33 285079ff
Pipeline #92299099 passed with stage
in 5 minutes and 53 seconds
{% extends 'dash/base.html' %}
{% load static %}
{% load humanize %}
{% block title %}
Invoices for Batch {{ batch.pk }}, {{ batch.batch_date|date:'d/m/Y'}}
......@@ -30,65 +29,13 @@
<table class="pure-table pure-table-bordered" width="100%">
<tbody>
{% include 'invoice/_batch_header.html' %}
<tr>
<td>
Net
</td>
<td>
{{ batch_net|intcomma }}
</td>
</tr>
<tr>
<td>
VAT
</td>
<td>
{{ batch_vat|intcomma }}
</td>
</tr>
<tr>
<td>
Gross
</td>
<td>
{{ batch_gross|intcomma }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="pure-g">
<div class="pure-u-1">
<table class="pure-table pure-table-bordered small-margin-top" width="100%">
<thead>
<tr valign="top">
<th>Date</th>
<th>Invoice Number</th>
<th>Contact</th>
</tr>
</thead>
<tbody>
{% for o in object_list %}
<tr valign="top">
<td>
{{ o.invoice.invoice_date|date:'d/m/Y'}}
</td>
<td>
<a href="{% url 'invoice.detail' o.invoice.pk %}">
{{ o.invoice.invoice_number }}
</a>
</td>
<td>
<a href="{% url 'contact.detail' o.invoice.contact.pk %}">
{{ o.invoice.contact }}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% include 'invoice/_batch_invoice_list.html' %}
</div>
</div>
<div class="pure-g">
......
......@@ -4,10 +4,52 @@ import pytest
from django.urls import reverse
from http import HTTPStatus
from invoice.tests.factories import InvoiceCreditFactory, InvoiceFactory
from invoice.tests.factories import (
BatchFactory,
BatchInvoiceFactory,
InvoiceCreditFactory,
InvoiceFactory,
)
from login.tests.factories import TEST_PASSWORD, UserFactory
@pytest.mark.django_db
def test_batch_invoice_list(client):
batch = BatchFactory()
BatchInvoiceFactory(
batch=batch,
invoice=InvoiceFactory(invoice_date=batch.batch_date, number=1),
)
BatchInvoiceFactory(
batch=BatchFactory(),
invoice=InvoiceFactory(invoice_date=batch.batch_date, number=2),
)
BatchInvoiceFactory(
batch=batch,
invoice=InvoiceFactory(invoice_date=batch.batch_date, number=3),
)
BatchInvoiceFactory(
batch=batch,
invoice=InvoiceFactory(invoice_date=batch.batch_date, number=4),
)
user = UserFactory(is_staff=True)
assert client.login(username=user.username, password=TEST_PASSWORD) is True
response = client.get(
reverse("invoice.batch.invoice.list", args=[batch.pk])
)
assert HTTPStatus.OK == response.status_code
assert "batch" in response.context
assert "batch_gross" in response.context
assert "batch_net" in response.context
assert "batch_vat" in response.context
assert "object_list" in response.context
assert "batchinvoice_list" in response.context
assert batch == response.context["batch"]
assert [4, 3, 1] == [
x.invoice.number for x in response.context["batchinvoice_list"]
]
@pytest.mark.django_db
def test_invoice_detail(client):
invoice = InvoiceFactory()
......
......@@ -5,12 +5,23 @@ from django.urls import reverse
from contact.tests.factories import ContactFactory, UserContactFactory
from crm.tests.factories import CrmContactFactory, TicketFactory
from invoice.tests.factories import InvoiceContactFactory, InvoiceFactory
from invoice.tests.factories import (
BatchFactory,
InvoiceContactFactory,
InvoiceFactory,
)
from login.tests.factories import TEST_PASSWORD, UserFactory
from login.tests.fixture import perm_check
from login.tests.scenario import get_user_web
@pytest.mark.django_db
def test_batch_invoice_list(perm_check):
batch = BatchFactory()
url = reverse("invoice.batch.invoice.list", args=[batch.pk])
perm_check.staff(url)
@pytest.mark.django_db
def test_contact_detail(perm_check):
contact = ContactFactory()
......
......@@ -7,6 +7,7 @@ from django.views.generic import RedirectView
from example_invoice.views import InvoiceListView
from .views import (
BatchInvoiceListView,
ContactDetailView,
ContactListView,
HomeView,
......@@ -22,6 +23,11 @@ urlpatterns = [
path("admin/", admin.site.urls),
url(regex=r"^$", view=HomeView.as_view(), name="project.home"),
url(regex=r"^", view=include("login.urls")),
url(
regex=r"^batch/(?P<pk>\d+)/invoice/$",
view=BatchInvoiceListView.as_view(),
name="invoice.batch.invoice.list",
),
url(regex=r"^contact/", view=include("contact.urls")),
url(
regex=r"^contact/(?P<pk>\d+)/$",
......
......@@ -4,7 +4,21 @@ from django.views.generic import DetailView, TemplateView, ListView
from base.view_utils import BaseMixin
from contact.views import ContactDetailMixin, ContactListMixin
from invoice.views import InvoiceDetailMixin, InvoiceListMixin
from invoice.views import (
BatchInvoiceListMixin,
InvoiceDetailMixin,
InvoiceListMixin,
)
class BatchInvoiceListView(
LoginRequiredMixin,
StaffuserRequiredMixin,
BatchInvoiceListMixin,
BaseMixin,
ListView,
):
template_name = "example/batch_invoice_list.html"
class ContactDetailView(
......
......@@ -9,6 +9,7 @@ from .models import (
get_contact_model,
Invoice,
InvoiceContact,
InvoiceIssue,
InvoiceLine,
InvoiceUser,
PaymentProcessor,
......@@ -52,6 +53,19 @@ class InvoiceBlankTodayForm(RequiredFieldForm):
fields = ("currency", "iteration_end")
class InvoiceIssueForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["comment"].widget.attrs.update(
{"class": "pure-input-1", "rows": 5}
)
self.fields["confirmed"].label = "Issue resolved"
class Meta:
model = InvoiceIssue
fields = ("confirmed", "comment")
class InvoiceEmptyForm(forms.ModelForm):
class Meta:
model = Invoice
......@@ -144,8 +158,8 @@ class TimeRecordForm(RequiredFieldForm):
class SlugModelChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
return obj.slug
def label_from_instance(self, x):
return x.slug
class SearchForm(forms.Form):
......@@ -155,7 +169,7 @@ class SearchForm(forms.Form):
currency = SlugModelChoiceField(
label="Currency", queryset=Currency.objects.none(), required=False
)
payment_processor = SlugModelChoiceField(
payment_processor = forms.ModelChoiceField(
label="Payment",
queryset=PaymentProcessor.objects.none(),
required=False,
......@@ -168,20 +182,25 @@ class SearchForm(forms.Form):
super().__init__(*args, **kwargs)
# invoice date
f = self.fields["invoice_date"]
f.label = ""
f.widget.attrs.update({"class": "datepicker pure-u-23-24"})
# invoice number
f = self.fields["number"]
f.label = ""
f.widget.attrs.update(
{"class": "pure-u-23-24", "placeholder": "Number"}
)
# currency
f = self.fields["currency"]
f.empty_label = "All currencies..."
f.label = ""
f.queryset = currency_qs
f.widget.attrs.update({"class": "chosen-select pure-u-23-24"})
# payment processor
f = self.fields["payment_processor"]
if display_payment_processor:
f.empty_label = "All processors..."
f.label = ""
f.queryset = payment_processor_qs
f.widget.attrs.update({"class": "chosen-select pure-u-23-24"})
else:
......
# Generated by Django 2.2.6 on 2019-10-29 12:35
from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("finance", "0009_auto_20190731_1525"),
("invoice", "0021_auto_20190322_1235"),
]
operations = [
migrations.CreateModel(
name="InvoiceIssue",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("confirmed", models.BooleanField(default=False)),
("comment", models.TextField(blank=True, null=True)),
(
"confirmed_by_user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Invoice Issue",
"verbose_name_plural": "Invoice Issues",
"ordering": ("-invoice__invoice_date", "-invoice__pk"),
},
),
migrations.AlterModelOptions(
name="batch",
options={
"ordering": (
"batch_date",
"currency",
"exchange_rate",
"payment_processor",
),
"verbose_name": "Invoice Batch",
"verbose_name_plural": "Invoice Batches",
},
),
migrations.AlterModelOptions(
name="paymentprocessor",
options={
"ordering": ("description",),
"verbose_name": "Payment Processor",
},
),
migrations.RemoveField(model_name="paymentprocessor", name="slug"),
migrations.AddField(
model_name="batch",
name="exchange_rate",
field=models.DecimalField(
decimal_places=3, default=Decimal("0"), max_digits=5
),
),
migrations.AddField(
model_name="invoice",
name="exchange_rate",
field=models.DecimalField(
decimal_places=3, default=Decimal("0"), max_digits=5
),
),
migrations.AddField(
model_name="invoicecontact",
name="country",
field=models.ForeignKey(
blank=True,
help_text="Invoicing country (for VAT)",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="finance.Country",
),
),
migrations.AddField(
model_name="invoicecontact",
name="vat_number",
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name="invoiceline",
name="discount",
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=8, null=True
),
),
migrations.AlterField(
model_name="paymentprocessor",
name="description",
field=models.CharField(max_length=100, unique=True),
),
migrations.AlterUniqueTogether(
name="batch",
unique_together={
("batch_date", "currency", "exchange_rate", "payment_processor")
},
),
migrations.CreateModel(
name="InvoiceIssueLine",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created", models.DateTimeField(auto_now_add=True)),
("modified", models.DateTimeField(auto_now=True)),
("description", models.TextField()),
(
"invoice_issue",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="invoice.InvoiceIssue",
),
),
],
options={
"verbose_name": "Invoice Issue Line",
"verbose_name_plural": "Invoice Issue Lines",
"ordering": (
"-invoice_issue__invoice__invoice_date",
"-invoice_issue__invoice__pk",
"-pk",
),
},
),
migrations.AddField(
model_name="invoiceissue",
name="invoice",
field=models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
to="invoice.Invoice",
),
),
]
......@@ -4,7 +4,7 @@ import collections
from datetime import date, datetime
from dateutil.relativedelta import relativedelta
from dateutil.rrule import WEEKLY, rrule, SU
from dateutil.rrule import SU, WEEKLY, rrule
from decimal import Decimal
from django.apps import apps
from django.conf import settings
......@@ -16,15 +16,15 @@ from django.utils.timesince import timeuntil
from reversion import revisions as reversion
from base.model_utils import (
private_file_store,
TimedCreateModifyDeleteModel,
TimedCreateModifyDeleteVersionModel,
TimedCreateModifyDeleteVersionModelManager,
TimeStampedModel,
private_file_store,
)
from base.singleton import SingletonModel
from crm.models import Note, Ticket
from finance.models import VatCode, Currency
from finance.models import Country, Currency, VatCode, VatSettings
from stock.models import Product
......@@ -178,6 +178,43 @@ class TimeAnalysis:
return format_minutes(self.charge + self.fixed + self.non_charge)
class InvoiceContactManager(models.Manager):
def _create_invoice_contact(
self, contact, country, hourly_rate, vat_number
):
x = self.model(
contact=contact,
country=country,
hourly_rate=hourly_rate,
vat_number=vat_number or "",
)
x.save()
return x
def init_invoice_contact(
self, contact, country=None, hourly_rate=None, vat_number=None
):
try:
x = self.model.objects.get(contact=contact)
update = False
if country:
x.country = country
update = True
if hourly_rate:
x.hourly_rate = hourly_rate
update = True
if vat_number:
x.vat_number = vat_number
update = True
if update:
x.save()
except self.model.DoesNotExist:
x = self._create_invoice_contact(
contact, country, hourly_rate, vat_number
)
return x
class InvoiceContact(TimeStampedModel):
contact = models.OneToOneField(
......@@ -186,6 +223,19 @@ class InvoiceContact(TimeStampedModel):
hourly_rate = models.DecimalField(
blank=True, null=True, max_digits=8, decimal_places=2
)
country = models.ForeignKey(
Country,
help_text="Invoicing country (for VAT)",
blank=True,
null=True,
on_delete=models.CASCADE,
related_name="+",
)
# Maximum of 12 characters?
# https://www.gov.uk/guidance/vat-eu-country-codes-vat-numbers-and-vat-in-other-languages
# 10/09/2019 The Xero 'TaxNumber' has a maximum length of 50 characters.
vat_number = models.CharField(max_length=50, blank=True)
objects = InvoiceContactManager()
class Meta:
verbose_name = "Invoice Contact"
......@@ -195,11 +245,21 @@ class InvoiceContact(TimeStampedModel):
result = "{}".format(self.contact.get_full_name)
if self.hourly_rate:
result = "{} @ {}".format(result, self.hourly_rate)
if self.vat_number:
result = "{}, VAT Number {}".format(result, self.vat_number)
return result
def get_absolute_url(self):
return self.contact.get_absolute_url()
def is_european_union_vat_transaction(self):
result = False
if self.country and self.vat_number:
if VatSettings.objects.european_union_countries_initialised():
if VatSettings.objects.is_european_union_country(self.country):
result = True
return result
reversion.register(InvoiceContact)
......@@ -214,35 +274,25 @@ class InvoiceError(Exception):
class PaymentProcessorManager(models.Manager):
def _create_payment_processor(self, slug, description):
x = self.model(slug=slug, description=description)
def create_payment_processor(self, description):
x = self.model(description=description)
x.save()
return x
def init_payment_processor(self, slug, description):
try:
x = self.model.objects.get(slug=slug)
x.description = description
x.save()
except self.model.DoesNotExist:
x = self._create_payment_processor(slug, description)
return x
class PaymentProcessor(TimeStampedModel):
slug = models.SlugField(max_length=30, unique=True)
description = models.CharField(max_length=100)
description = models.CharField(max_length=100, unique=True)
deleted = models.BooleanField(default=False)
objects = PaymentProcessorManager()
class Meta:
ordering = ("description",)
verbose_name = "Payment Processor"
def __str__(self):
if self.description:
return "{} - {}".format(self.slug, self.description)
else:
return self.slug
return "{}{}".format(
self.description, " (deleted)" if self.deleted else ""
)
class InvoiceManager(TimedCreateModifyDeleteVersionModelManager):
......@@ -260,13 +310,15 @@ class InvoiceManager(TimedCreateModifyDeleteVersionModelManager):
def not_upfront_payment(self, invoice_date):
"""List of invoices which cannot be added to a batch.
We use a ``Batch`` to group invoices which were for paid *on order*
(``upfront_payment=True``).
- Exclude credit notes.
- We use a ``Batch`` to group invoices which were paid for *on order*
(``upfront_payment=True``).