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

Ttva 146 sap xml doc #155

Merged
merged 9 commits into from
Aug 8, 2023
15 changes: 15 additions & 0 deletions locale/fi/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,12 @@ msgstr "Varauksen teko menneisyyteen ei ole sallittua"
msgid "You cannot request an invoice for this resource"
msgstr "Ei ole sallittua pyytää laskua tästä resurssista"

msgid "Invoice from %(begin)s to %(end)s"
msgstr "Lasku ajalta %(begin)s - %(end)s"

msgid "Invoice for %(begin)s"
msgstr "Lasku ajalta %(begin)s"

#, python-format
msgid "The resource is reservable only before %(datetime)s"
msgstr "Tila on varattavissa vain ennen %(datetime)s"
Expand Down Expand Up @@ -2018,6 +2024,15 @@ msgstr "Kirjanpito"
msgid "Cost center code"
msgstr "Kustannuspaikkakoodi"

msgid "CeePos Cost center code"
msgstr "CeePoksen Kustannuspaikkakoodi"

msgid "SAP Cost center code"
msgstr "SAP:in Kustannuspaikkakoodi"

msgid "SAP Sales Organization code"
msgstr "SAP:in Myyntiorganisaatiokoodi"

msgid ""
"This resource's unit doesn't have a cost center code set. "
"Please set one in the unit settings to ensure payments go through."
Expand Down
47 changes: 47 additions & 0 deletions payments/management/commands/generate_sap_invoices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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.
"""
reservations = get_reservations_for_invoicing()
sales_order_xml = generate_sales_order(reservations)

if sales_order_xml:
now = timezone.now()
# mark reservations invoices as done
reservations.update(invoice_generated_at=now)

if options["target_dir"]:
filename = now.strftime("%d-%m-%Y-%H-%M.xml")
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)
115 changes: 115 additions & 0 deletions payments/sap_invoices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import datetime
from django.conf import settings
from django.template import loader
from django.utils import timezone
from django.utils.translation import gettext as _

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


def get_reservations_for_invoicing():
"""Returns reservations that are eligible for invoice generation.

This should include all CONFIRMED reservations marked invoice approved.
"""
return (
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")
)


def get_sales_order_items(reservation):
cost_center_code = reservation.resource.unit.sap_cost_center_code

for line in reservation.get_order().order_lines.all():
yield {
"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",
# before tax
"price_condition": "ZYMH",
}


def get_sales_orders(reservations):
"""Returns the sales order context data."""
now = timezone.now()

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

if (
reservation.begin
and reservation.end
and (reservation.end - reservation.begin) > datetime.timedelta(hours=24)
):
billing_period = _("Invoice from %(begin)s to %(end)s") % {
"begin": reservation.begin.strftime("%d.%m.%Y"),
"end": reservation.end.strfime("%d.%m.%Y"),
}
elif reservation.begin:
billing_period = _("Invoice for %(begin)s") % {
"begin": reservation.begin.strftime("%d.%m.%Y"),
}
else:
billing_period = ""

yield {
"reference": reservation.pk,
"business_id": reservation.reserver_id,
"company_name": reservation.company,
"address": address,
"billing_date": now,
"billing_period": billing_period,
"interface_id": settings.RESPA_SAP_INTERFACE_ID,
# assumed defaults
"distribution_channel": "00",
"division": "00",
"sales_order_type": "Z001",
"items": get_sales_order_items(reservation),
}


def generate_sales_order(reservations):
"""Generates XML document as a string containing all invoices.

This document can be then sent to SAP for processing.

If no available invoiceable reservations, returns `None`.
"""

return (
loader.render_to_string(
"payments/sap/sales_order.xml",
{
"orders": get_sales_orders(reservations),
},
)
if reservations.exists()
else None
)
43 changes: 43 additions & 0 deletions payments/templates/payments/sap/sales_order.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{% load l10n %}
<?xml version="1.0" encoding="UTF-8"?>
<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>
<YourReference>{{ order.reference }}</YourReference>
<SalesOrganisation>{{ order.sales_organization }}</SalesOrganisation>
<DistributionChannel>{{ order.distribution_channel }}</DistributionChannel>
<Division>{{ order.division }}</Division>
<SalesOrderType>{{ order.sales_order_type }}</SalesOrderType>
<InterfaceID>{{ order.interface_id }}</InterfaceID>
<Text>
<TextRow>{{ billing_period }}</TextRow>
</Text>
<Items>
{% for item in order.items %}
<Item>
<Description>{{ item.description }}</Description>
<ProfitCenter>{{ item.cost_center_code }}</ProfitCenter>
<Material>{{ item.material }}</Material>
<PriceCondition>{{ item.price_condition }}</PriceCondition>
<Currency>{{ item.currency }}</Currency>
<Quantity>{{ item.quantity }}</Quantity>
<UnitPrice>{{ item.unit_price|unlocalize }}</UnitPrice>
jopesy marked this conversation as resolved.
Show resolved Hide resolved
</Item>
{% endfor %}
</Items>
</header>
{% endfor %}
</SalesOrder>
82 changes: 82 additions & 0 deletions payments/tests/test_sap_invoices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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):
assert get_reservations_for_invoicing().count() == 1


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


@pytest.mark.django_db()
def test_get_reservations_for_invoicing_already_generated(invoice_reservation):
Reservation.objects.update(invoice_generated_at=timezone.now())
assert get_reservations_for_invoicing().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

items = list(order["items"])
assert len(items) == 1
item = items[0]
assert item["quantity"] == 1
assert item["unit_price"] == Decimal("100.00")


@pytest.mark.django_db()
def test_generate_sales_order(invoice_reservation):
xml_str = generate_sales_order(get_reservations_for_invoicing())
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():
assert generate_sales_order(get_reservations_for_invoicing()) 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))
Loading
Loading