Skip to content

Commit

Permalink
Payments: add sales order command to payments app and add new
Browse files Browse the repository at this point in the history
sap_invoices module for handling logic.

Refs: TTVA-146
  • Loading branch information
danjacob-anders committed Aug 7, 2023
1 parent 6805b6b commit bea2e96
Show file tree
Hide file tree
Showing 5 changed files with 299 additions and 9 deletions.
44 changes: 44 additions & 0 deletions payments/management/commands/generate_sap_invoices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import pathlib
from django.core.management.base import BaseCommand
from django.template import loader
from django.utils import timezone
from django.utils.translation import gettext as _

from payments.sap_invoices import generate_sales_order, get_reservations_for_invoicing


class Command(BaseCommand):
help = "Generates SAP XML Sales Order documents."

def add_arguments(self, parser):
parser.add_argument("--target_dir", help="Target directory")
parser.add_argument(
"--stdout",
help="Write to STDOUT",
action="store_true",
default=False,
)

def handle(self, *args, **options):
"""Should generate SAP XML documents for all reservations where an
invoice has been requested and approved.
Documents are written to the target directory, using timestamped folders.
TBD: provide optional URL for (s)ftp transfer instead of writing to dir.
"""
with get_reservations_for_invoicing() as reservations:
sales_order_xml = generate_sales_order(reservations)

if sales_order_xml:
filename = timezone.now().strftime("%d-%m-%Y-%H-%M.xml")

if options["target_dir"]:
target_dir = pathlib.Path(options["target_dir"])
target_dir.mkdir(exist_ok=True, parents=True)

with open(target_dir / filename, "w") as fp:
fp.write(sales_order_xml)

if options["stdout"]:
self.stdout.write(sales_order_xml)
98 changes: 98 additions & 0 deletions payments/sap_invoices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import contextlib
from django.template import loader
from django.utils import timezone

from payments.utils import round_price
from resources.models import Reservation


@contextlib.contextmanager
def get_reservations_for_invoicing():
"""Returns reservations that are eligible for invoice generation.
This should include all CONFIRMED reservations marked invoice approved.
Will mark reservations as generated when done.
"""
reservations = (
Reservation.objects.filter(
state=Reservation.CONFIRMED,
order__isnull=False,
invoice_requested=True,
invoice_approved_at__isnull=False,
invoice_generated_at__isnull=True,
)
.select_related(
"resource",
"resource__unit",
"order",
)
.prefetch_related(
"order__order_lines",
"order__order_lines__product",
)
.order_by("invoice_approved_at")
)

yield reservations

reservations.update(invoice_generated_at=timezone.now())


def get_sales_orders(reservations):
now = timezone.now()

for reservation in reservations:
cost_center_code = reservation.resource.unit.sap_cost_center_code
unit_id = reservation.resource.unit.pk

items = [
{
"currency": "EUR",
"description": line.product.name,
# always use "1" as SAP will calculate total price based on units
"quantity": 1,
"unit_price": round_price(line.total_price),
"profit_center": cost_center_code,
# dummy value
"material": "1111",
"plant": unit_id,
}
for line in reservation.get_order().order_lines.all()
]

address = {
"street": reservation.company_address_street,
"town": reservation.company_address_city,
"postcode": reservation.company_address_zip,
}

yield {
"reference": reservation.pk,
"business_id": reservation.reserver_id,
"company_name": reservation.company,
"address": address,
"billing_date": now,
"items": items,
}


def generate_sales_order(reservations):
"""Generates XML document as a string containing all invoices for reservations and updates
database to indicate these reservations have been processed for invoicing.
If no available invoiceable reservations, returns `None`.
<TextRow>Lasku ajalta {{ reservation.begin|date:"d.m.Y" }} - {{ reservation.end|date:"d.m.Y" }}</TextRow>
"""

return (
loader.render_to_string(
"payments/sap/sales_order.xml",
{
"orders": get_sales_orders(reservations),
},
)
if reservations.exists()
else None
)
47 changes: 47 additions & 0 deletions payments/templates/payments/sap/sales_order.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
{% load l10n %}
<SalesOrder>
{% for order in orders %}
<header>
<Customer>
<SapID></SapID>
<Company>
<BusinessID>{{ order.business_id }}</BusinessID>
<CompanyName>{{ order.company_name }}</CompanyName>
</Company>
<address>
<Street>{{ order.address.street }}</Street>
<Town>{{ order.address.town }}</Town>
<PostCode>{{ order.address.postcode }}</PostCode>
</address>
</Customer>
<BillingDate>{{ order.billing_date }}</BillingDate>
<ServiceRenderDate>{% now "Y-d-m" %}</ServiceRenderDate>
<BillNumber>20186245</BillNumber>
<YourReference>{{ order.reference }}</YourReference>
<SalesOrganisation>1111</SalesOrganisation>
<DistributionChannel>00</DistributionChannel>
<Division>00</Division>
<SalesOrderType>ZXXX</SalesOrderType>
<InterfaceID>XXX</InterfaceID>
<Text>
<TextRow>Lasku ajalta 3.10.2023 - 4.10.2023</TextRow>
</Text>
<Items>
{% for item in order.items %}
<Item>
<Description>{{ item.description }}</Description>
<ProfitCenter>{{ item.cost_center_code }}</ProfitCenter>
<InternalOrder>000000027070</InternalOrder>
<Material>1111111</Material>
<PriceCondition>ZMYH</PriceCondition>
<Currency>{{ item.currency }}</Currency>
<Quantity>{{ item.quantity }}</Quantity>
<UnitPrice>{{ item.unit_price|unlocalize }}</UnitPrice>
<Plant>{{ item.plant }}</Plant>
</Item>
{% endfor %}
</Items>
</header>
{% endfor %}
</SalesOrder>
90 changes: 90 additions & 0 deletions payments/tests/test_sap_invoices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import pytest
from datetime import timedelta
from decimal import Decimal
from django.utils import timezone

from payments.factories import OrderFactory, OrderLineFactory
from payments.models import Order
from payments.sap_invoices import (
generate_sales_order,
get_reservations_for_invoicing,
get_sales_orders,
)
from resources.models import Reservation


@pytest.fixture()
def invoice_reservation(resource_with_opening_hours, user):
now = timezone.now()
reservation = Reservation.objects.create(
resource=resource_with_opening_hours,
begin=now,
end=now + timedelta(hours=2),
user=user,
state=Reservation.CONFIRMED,
invoice_requested=True,
invoice_requested_at=now,
invoice_approved_at=now,
invoice_generated_at=None,
reserver_id="Y-12456",
company="test",
company_address_street="123 Pihlajakatu",
company_address_city="Helsinki",
company_address_zip="11000",
)

order = OrderFactory(reservation=reservation, state=Order.CONFIRMED)
OrderLineFactory(order=order)
return reservation


@pytest.mark.django_db()
def test_get_reservations_for_invoicing(invoice_reservation):
with get_reservations_for_invoicing() as reservations:
assert reservations.count() == 1

# should be marked as generated
assert Reservation.objects.filter(
invoice_generated_at__isnull=False,
).exists()


@pytest.mark.django_db()
def test_get_reservations_for_invoicing_not_approved(invoice_reservation):
Reservation.objects.update(invoice_approved_at=None)
with get_reservations_for_invoicing() as reservations:
assert reservations.count() == 0


@pytest.mark.django_db()
def test_get_reservations_for_invoicing_already_generated(invoice_reservation):
Reservation.objects.update(invoice_generated_at=timezone.now())
with get_reservations_for_invoicing() as reservations:
assert reservations.count() == 0


@pytest.mark.django_db()
def test_get_sales_orders(invoice_reservation):
sales_orders = list(get_sales_orders([invoice_reservation]))
order = sales_orders[0]
assert order["business_id"] == invoice_reservation.reserver_id
assert order["address"]["town"] == invoice_reservation.company_address_city
assert len(order["items"]) == 1
item = order["items"][0]
assert item["quantity"] == 1
assert item["unit_price"] == Decimal("100.00")


@pytest.mark.django_db()
def test_generate_sales_order(invoice_reservation):
with get_reservations_for_invoicing() as reservations:
xml_str = generate_sales_order(reservations)
assert "<BusinessID>Y-12456" in xml_str
assert "<UnitPrice>100.00" in xml_str
assert "<Town>Helsinki" in xml_str


@pytest.mark.django_db()
def test_generate_sales_order_if_empty():
with get_reservations_for_invoicing() as reservations:
assert generate_sales_order(reservations) is None
29 changes: 20 additions & 9 deletions payments/utils.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
from datetime import timedelta
from decimal import ROUND_HALF_UP, Decimal
from functools import wraps

from django.utils.translation import ugettext_lazy as _
from functools import wraps


def price_as_sub_units(price: Decimal) -> int:
"""Provides price in currency sub units e.g. cents for payment providers
that require pricing in sub unit format."""
return int(round_price(price) * 100)


def round_price(price: Decimal) -> Decimal:
return price.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
return price.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)


def rounded(func):
Expand All @@ -21,30 +22,40 @@ def rounded(func):
can be turned off by giving parameter "rounded=False" when calling the
function.
"""

@wraps(func)
def wrapped(*args, **kwargs):
rounded = kwargs.pop('rounded', True)
rounded = kwargs.pop("rounded", True)
value = func(*args, **kwargs)
if rounded:
value = round_price(value)
return value

return wrapped


def convert_pretax_to_aftertax(pretax_price: Decimal, tax_percentage: Decimal) -> Decimal:
def convert_pretax_to_aftertax(
pretax_price: Decimal, tax_percentage: Decimal
) -> Decimal:
return pretax_price * (1 + tax_percentage / 100)


def convert_aftertax_to_pretax(aftertax_price: Decimal, tax_percentage: Decimal) -> Decimal:
def convert_aftertax_to_pretax(
aftertax_price: Decimal, tax_percentage: Decimal
) -> Decimal:
return aftertax_price / (1 + tax_percentage / 100)


def get_price_period_display(price_period):
if not price_period:
return None

hours = Decimal(price_period / timedelta(hours=1)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP).normalize()
hours = (
Decimal(price_period / timedelta(hours=1))
.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
.normalize()
)
if hours == 1:
return _('hour')
return _("hour")
else:
return _('{hours} hours'.format(hours=hours))
return _("{hours} hours".format(hours=hours))

0 comments on commit bea2e96

Please sign in to comment.