Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ install_requires =
pdfdocument
pdfrw2
pdftotext
pglast
pglast==7.16
phonenumbers
polib
psycopg[c]
Expand Down
1 change: 1 addition & 0 deletions src/onegov/org/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
'form': _('Selected Options'),
'manual': _('Discounts / Surcharges'),
'migration': _('Lump sum'),
'rounding': _('Rounding'),
}

PAYMENT_STATES: dict[PaymentState, str] = {
Expand Down
11 changes: 11 additions & 0 deletions src/onegov/org/forms/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2426,6 +2426,17 @@ class VATSettingsForm(Form):
validators=[InputRequired(), NumberRange(0, 100)],
)

price_rounding = BooleanField(
label=_('Round invoice totals to the nearest 0.05'),
description=_(
'If enabled, newly generated invoices include a '
'rounding position right before the total, so the '
'total is always a multiple of 0.05. Existing '
'invoices are not affected.'
),
default=False,
)


class ModuleActivationSettingsForm(Form):
show_newsletter = BooleanField(
Expand Down
18 changes: 18 additions & 0 deletions src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,24 @@ msgstr "Abzüge / Zuschläge"
msgid "Lump sum"
msgstr "Pauschalbetrag"

msgid "Rounding"
msgstr "Rundung"

msgid "Prices"
msgstr "Preise"

msgid "Round invoice totals to the nearest 0.05"
msgstr "Rechnungstotale auf 0.05 runden"

msgid ""
"If enabled, newly generated invoices include a rounding position right "
"before the total, so the total is always a multiple of 0.05. Existing "
"invoices are not affected."
msgstr ""
"Wenn aktiviert, wird bei neu erstellten Rechnungen eine Rundungsposition "
"vor dem Total eingefügt, sodass das Total immer ein Vielfaches von 0.05 "
"ist. Bestehende Rechnungen werden nicht angepasst."

msgid "Paid"
msgstr "Bezahlt"

Expand Down
18 changes: 18 additions & 0 deletions src/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,24 @@ msgstr "Remises / Suppléments"
msgid "Lump sum"
msgstr "Montant forfaitaire"

msgid "Rounding"
msgstr "Arrondissement"

msgid "Prices"
msgstr "Prix"

msgid "Round invoice totals to the nearest 0.05"
msgstr "Arrondir les totaux de facture à 0.05"

msgid ""
"If enabled, newly generated invoices include a rounding position right "
"before the total, so the total is always a multiple of 0.05. Existing "
"invoices are not affected."
msgstr ""
"Si activé, les nouvelles factures incluent une position d'arrondissement "
"avant le total, de sorte que le total est toujours un multiple de 0.05. "
"Les factures existantes ne sont pas affectées."

msgid "Paid"
msgstr "Payé"

Expand Down
18 changes: 18 additions & 0 deletions src/onegov/org/locale/it_CH/LC_MESSAGES/onegov.org.po
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,24 @@ msgstr "Deduzioni / Supplementi"
msgid "Lump sum"
msgstr "Somma forfettaria"

msgid "Rounding"
msgstr "Arrotondamento"

msgid "Prices"
msgstr "Prezzi"

msgid "Round invoice totals to the nearest 0.05"
msgstr "Arrotondare i totali delle fatture a 0.05"

msgid ""
"If enabled, newly generated invoices include a rounding position right "
"before the total, so the total is always a multiple of 0.05. Existing "
"invoices are not affected."
msgstr ""
"Se attivato, le fatture appena generate includono una posizione di "
"arrotondamento prima del totale, in modo che il totale sia sempre un "
"multiplo di 0.05. Le fatture esistenti non vengono modificate."

msgid "Paid"
msgstr "Pagato"

Expand Down
3 changes: 3 additions & 0 deletions src/onegov/org/models/organisation.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,9 @@ def get_kaba_configuration(
# vat
vat_rate: dict_property[float | None] = meta_property(default=0.0)

# prices
price_rounding: dict_property[bool] = meta_property(default=False)

# RIS settings
ris_enabled: dict_property[bool] = meta_property(default=False)
ris_main_url: dict_property[str | None] = meta_property(default=None)
Expand Down
44 changes: 32 additions & 12 deletions src/onegov/org/models/ticket.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
from onegov.org.views.utils import show_tags, show_filters
from onegov.org.utils import (
currency_for_submission,
invoice_items_for_submission
apply_price_rounding,
invoice_items_for_submission,
manual_invoice_items_total,
)
from onegov.pay import ManualPayment
from onegov.reservation import Allocation, Resource, Reservation
Expand Down Expand Up @@ -60,11 +62,16 @@ def submission_invoice_items(
self: FormSubmissionHandler | DirectoryEntryHandler,
request: CoreRequest
) -> list[InvoiceItemMeta]:
return invoice_items_for_submission(
request,
self.form, # type: ignore[arg-type]
self.submission
) if self.submission else []
return (
invoice_items_for_submission(
request,
self.form, # type: ignore[arg-type]
self.submission,
manual_total=manual_invoice_items_total(self.ticket.invoice),
)
if self.submission
else []
)


def refresh_submission_invoice_items(
Expand Down Expand Up @@ -115,6 +122,9 @@ def refresh_submission_invoice_items(
if item.group == 'submission':
existing = item
break
elif meta.group == 'rounding':
existing = item
break
else:
raise AssertionError('unreachable')

Expand Down Expand Up @@ -631,11 +641,21 @@ def invoice_items(self, request: CoreRequest) -> list[InvoiceItemMeta]:
extras = []
discounts = []

return self.resource.invoice_items_for_reservation(
self.reservations,
extras,
discounts,
reduced_amount_label=request.translate(_('Discount'))
return (

apply_price_rounding(
request,
self.resource.invoice_items_for_reservation(
self.reservations,
extras,
discounts,
reduced_amount_label=request.translate(_('Discount')),
),
manual_total=manual_invoice_items_total(
self.ticket.invoice
),
)

) if self.resource else []

def refresh_invoice_items(self, request: CoreRequest) -> None:
Expand Down Expand Up @@ -689,7 +709,7 @@ def refresh_invoice_items(self, request: CoreRequest) -> None:
if meta.family == item.family:
existing = item
break
elif meta.group == 'reduced_amount':
elif meta.group in ('reduced_amount', 'rounding'):
existing = item
break
else:
Expand Down
3 changes: 3 additions & 0 deletions src/onegov/org/templates/macros.pt
Original file line number Diff line number Diff line change
Expand Up @@ -2483,6 +2483,9 @@
<tal:b tal:case="'migration'" i18n:translate>
Lump sum
</tal:b>
<tal:b tal:case="'rounding'" i18n:translate>
Rounding
</tal:b>
<tal:b tal:case="default">
${item.group}
</tal:b>
Expand Down
55 changes: 52 additions & 3 deletions src/onegov/org/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,14 @@
from onegov.org import _
from onegov.org.elements import DeleteLink, Link
from onegov.org.models.search import Search
from onegov.pay import InvoiceItemMeta, Price
from onegov.pay import InvoiceItemMeta, Price, round_to_five_rappen
from onegov.reservation import Resource
from onegov.ticket import Ticket, TicketCollection, TicketPermission
from onegov.ticket import (
Ticket,
TicketCollection,
TicketInvoice,
TicketPermission,
)
from onegov.user import Auth, User, UserGroup
from operator import add
from sqlalchemy import case, nullsfirst
Expand Down Expand Up @@ -1942,7 +1947,8 @@ def get_current_tickets_url(request: OrgRequest) -> str:
def invoice_items_for_submission(
request: CoreRequest,
form: Form,
submission: FormSubmission
submission: FormSubmission,
manual_total: Decimal = Decimal(0),
) -> list[InvoiceItemMeta]:

# NOTE: Eventually we may need something more sophisticated
Expand Down Expand Up @@ -1978,6 +1984,47 @@ def invoice_items_for_submission(
items.append(item)
total = remainder

return apply_price_rounding(request, items, manual_total)


def manual_invoice_items_total(invoice: TicketInvoice | None) -> Decimal:
if invoice is None:
return Decimal(0)

return sum(
(item.amount for item in invoice.items if item.group == 'manual'),
start=Decimal(0),
)


def apply_price_rounding(
request: CoreRequest,
items: list[InvoiceItemMeta],
manual_total: Decimal = Decimal(0),
) -> list[InvoiceItemMeta]:
"""Appends a rounding position to the invoice items, so the total
ends up a multiple of 0.05, if the organisation has price rounding
enabled.

Manually added invoice items are preserved on refresh rather than
regenerated, so their total is passed separately to ensure the
grand total ends up on the 0.05 grid.

"""
org = getattr(request.app, 'org', None)
if org is None or not org.price_rounding or not items:
return items

total = InvoiceItemMeta.total(items) + manual_total
difference = round_to_five_rappen(total) - total
if difference:
items.append(
InvoiceItemMeta(
text=request.translate(_('Rounding')),
group='rounding',
unit=difference,
)
)
return items


Expand Down Expand Up @@ -2015,6 +2062,8 @@ def sort_key(item: T) -> tuple[int, str]:
return 1, item.group
case 'manual' | 'reduced_amount':
return 99, 'manual'
case 'rounding':
return 100, item.group
case _:
return 2, item.group

Expand Down
26 changes: 16 additions & 10 deletions src/onegov/org/views/reservation.py
Original file line number Diff line number Diff line change
Expand Up @@ -634,11 +634,14 @@ def confirm_reservation(
if failed and failed.isdigit()
}

invoice_items = self.invoice_items_for_reservation(
reservations,
extras,
discounts,
reduced_amount_label=request.translate(_('Discount'))
invoice_items = utils.apply_price_rounding(
request,
self.invoice_items_for_reservation(
reservations,
extras,
discounts,
reduced_amount_label=request.translate(_('Discount')),
),
)
total_amount = InvoiceItemMeta.total(invoice_items)
price = request.app.adjust_price(Price(
Expand Down Expand Up @@ -732,11 +735,14 @@ def finalize_reservation(self: Resource, request: OrgRequest) -> Response:
extras = []
discounts = []

invoice_items = self.invoice_items_for_reservation(
reservations,
extras,
discounts,
reduced_amount_label=request.translate(_('Discount'))
invoice_items = utils.apply_price_rounding(
request,
self.invoice_items_for_reservation(
reservations,
extras,
discounts,
reduced_amount_label=request.translate(_('Discount')),
),
)
amount = InvoiceItemMeta.total(invoice_items)
price = request.app.adjust_price(Price(
Expand Down
17 changes: 11 additions & 6 deletions src/onegov/org/views/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,18 +243,23 @@ def handle_footer_settings(


@OrgApp.form(
model=Organisation, name='vat-settings', template='form.pt',
permission=Secret, form=VATSettingsForm, setting=_('Value Added Tax'),
icon='fa-money', order=60)
model=Organisation,
name='vat-settings',
template='form.pt',
permission=Secret,
form=VATSettingsForm,
setting=_('Prices'),
icon='fa-money',
order=60,
)
def handle_vat_settings(
self: Organisation,
request: OrgRequest,
form: VATSettingsForm,
layout: SettingsLayout | None = None
) -> RenderData | Response:
layout = layout or SettingsLayout(self, request, _('Value Added Tax'))
return handle_generic_settings(self, request, form, _('Value Added Tax'),
layout)
layout = layout or SettingsLayout(self, request, _('Prices'))
return handle_generic_settings(self, request, form, _('Prices'), layout)


@OrgApp.form(
Expand Down
4 changes: 3 additions & 1 deletion src/onegov/pay/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from onegov.pay.utils import InvoiceDiscountMeta
from onegov.pay.utils import InvoiceItemMeta
from onegov.pay.utils import Price
from onegov.pay.utils import round_to_five_rappen


__all__ = (
Expand All @@ -51,5 +52,6 @@
'PaymentProviderCollection',
'Price',
'process_payment',
'payments_association_table_for'
'payments_association_table_for',
'round_to_five_rappen',
)
7 changes: 6 additions & 1 deletion src/onegov/pay/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal
from decimal import Decimal, ROUND_HALF_UP
from functools import cached_property, total_ordering
from onegov.core.orm import Base
from onegov.pay.constants import SCALE
Expand All @@ -16,6 +16,11 @@
from typing import Self, SupportsIndex


def round_to_five_rappen(amount: Decimal) -> Decimal:
"""Rounds the given amount to the nearest 5 Rappen (0.05 CHF)."""
return (amount * 20).quantize(Decimal('1'), rounding=ROUND_HALF_UP) / 20


class _PriceBase(NamedTuple):
amount: Decimal
currency: str | None
Expand Down
Loading