Repository: bo4e/BO4E-python (language: python)

Issue:
Implement BO Rechnung
https://www.bo4e.de/dokumentation/geschaeftsobjekte/bo-rechnung
requires
- #161 
- #160 
- #144 
- 
the bug #126 appliesin the validation


Discussion / hints:

Fix patch:
diff --git a/docs/api/bo4e.bo.rst b/docs/api/bo4e.bo.rst
index 64eb52e42..c5e9ec30e 100644
--- a/docs/api/bo4e.bo.rst
+++ b/docs/api/bo4e.bo.rst
@@ -36,6 +36,14 @@ bo4e.bo.energiemenge module
    :undoc-members:
    :show-inheritance:
 
+bo4e.bo.fremdkosten module
+--------------------------
+
+.. automodule:: bo4e.bo.fremdkosten
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
 bo4e.bo.geschaeftsobjekt module
 -------------------------------
 
@@ -100,6 +108,22 @@ bo4e.bo.preisblatt module
    :undoc-members:
    :show-inheritance:
 
+bo4e.bo.preisblattdienstleistung module
+---------------------------------------
+
+.. automodule:: bo4e.bo.preisblattdienstleistung
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+bo4e.bo.preisblatthardware module
+---------------------------------
+
+.. automodule:: bo4e.bo.preisblatthardware
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
 bo4e.bo.preisblattkonzessionsabgabe module
 ------------------------------------------
 
@@ -124,6 +148,14 @@ bo4e.bo.preisblattnetznutzung module
    :undoc-members:
    :show-inheritance:
 
+bo4e.bo.rechnung module
+-----------------------
+
+.. automodule:: bo4e.bo.rechnung
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
 bo4e.bo.region module
 ---------------------
 
@@ -164,6 +196,14 @@ bo4e.bo.tarifkosten module
    :undoc-members:
    :show-inheritance:
 
+bo4e.bo.tarifpreisblatt module
+------------------------------
+
+.. automodule:: bo4e.bo.tarifpreisblatt
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
 bo4e.bo.vertrag module
 ----------------------
 
diff --git a/src/bo4e/bo/rechnung.py b/src/bo4e/bo/rechnung.py
new file mode 100644
index 000000000..447c080bb
--- /dev/null
+++ b/src/bo4e/bo/rechnung.py
@@ -0,0 +1,133 @@
+"""
+Contains Rechnung class
+and corresponding marshmallow schema for de-/serialization
+"""
+
+from datetime import datetime
+from typing import List, Optional
+
+import attr
+from marshmallow import fields
+from marshmallow_enum import EnumField  # type:ignore[import]
+
+from bo4e.bo.geschaeftsobjekt import Geschaeftsobjekt, GeschaeftsobjektSchema
+from bo4e.bo.geschaeftspartner import Geschaeftspartner, GeschaeftspartnerSchema
+from bo4e.com.betrag import Betrag, BetragSchema
+from bo4e.com.rechnungsposition import Rechnungsposition, RechnungspositionSchema
+from bo4e.com.steuerbetrag import Steuerbetrag, SteuerbetragSchema
+from bo4e.com.zeitraum import Zeitraum, ZeitraumSchema
+from bo4e.enum.botyp import BoTyp
+from bo4e.enum.rechnungsstatus import Rechnungsstatus
+from bo4e.enum.rechnungstyp import Rechnungstyp
+
+
+# pylint: disable=too-few-public-methods, too-many-instance-attributes
+@attr.s(auto_attribs=True, kw_only=True)
+class Rechnung(Geschaeftsobjekt):
+    """
+    Modell für die Abbildung von Rechnungen im Kontext der Energiewirtschaft;
+    Ausgehend von diesem Basismodell werden weitere spezifische Formen abgeleitet.
+    """
+
+    # required attributes
+    bo_typ: BoTyp = attr.ib(default=BoTyp.RECHNUNG)
+    storno: bool = attr.ib(validator=attr.validators.instance_of(bool))
+    """
+    Kennzeichnung, ob es sich um eine Stornorechnung handelt;
+    im Falle "true" findet sich im Attribut "originalrechnungsnummer" die Nummer der Originalrechnung.
+    """
+    #: Eine im Verwendungskontext eindeutige Nummer für die Rechnung
+    rechnungsnummer: str = attr.ib(validator=attr.validators.instance_of(str))
+    #: Ausstellungsdatum der Rechnung
+    rechnungsdatum: datetime = attr.ib(validator=attr.validators.instance_of(datetime))
+    #: Zu diesem Datum ist die Zahlung fällig
+    faelligkeitsdatum: datetime = attr.ib(validator=attr.validators.instance_of(datetime))
+    #: Ein kontextbezogender Rechnungstyp, z.B. Netznutzungsrechnung
+    rechnungstyp: Rechnungstyp = attr.ib(validator=attr.validators.instance_of(Rechnungstyp))
+    #: Der Zeitraum der zugrunde liegenden Lieferung zur Rechnung
+    rechnungsperiode: Zeitraum = attr.ib(validator=attr.validators.instance_of(Zeitraum))
+    #: Der Aussteller der Rechnung
+    rechnungsersteller: Geschaeftspartner = attr.ib(validator=attr.validators.instance_of(Geschaeftspartner))
+    #: Der Aussteller der Rechnung
+    rechnungsempfaenger: Geschaeftspartner = attr.ib(validator=attr.validators.instance_of(Geschaeftspartner))
+    #: Die Summe der Nettobeträge der Rechnungsteile
+    gesamtnetto: Betrag = attr.ib(validator=attr.validators.instance_of(Betrag))
+    #: Die Summe der Steuerbeträge der Rechnungsteile
+    gesamtsteuer: Betrag = attr.ib(validator=attr.validators.instance_of(Betrag))
+    #: Die Summe aus Netto- und Steuerbetrag
+    gesamtbrutto: Betrag = attr.ib(validator=attr.validators.instance_of(Betrag))
+    #: Der zu zahlende Betrag, der sich aus (gesamtbrutto - vorausbezahlt - rabattBrutto) ergibt
+    zuzahlen: Betrag = attr.ib(validator=attr.validators.instance_of(Betrag))
+    #: Die Rechnungspositionen
+    rechnungspositionen: List[Rechnungsposition] = attr.ib(
+        validator=attr.validators.deep_iterable(
+            member_validator=attr.validators.instance_of(Rechnungsposition),
+            iterable_validator=attr.validators.instance_of(List),
+        )
+    )
+
+    # optional attributes
+    #: Bezeichnung für die vorliegende Rechnung
+    rechnungstitel: Optional[str] = attr.ib(
+        default=None, validator=attr.validators.optional(attr.validators.instance_of(str))
+    )
+    #: Status der Rechnung zur Kennzeichnung des Bearbeitungsstandes
+    rechnungsstatus: Optional[Rechnungsstatus] = attr.ib(
+        default=None, validator=attr.validators.optional(attr.validators.instance_of(Rechnungsstatus))
+    )
+    #: Im Falle einer Stornorechnung (storno = true) steht hier die Rechnungsnummer der stornierten Rechnung
+    original_rechnungsnummer: Optional[str] = attr.ib(
+        default=None, validator=attr.validators.optional(attr.validators.instance_of(str))
+    )
+    #: Die Summe evtl. vorausgezahlter Beträge, z.B. Abschläge. Angabe als Bruttowert
+    vorausgezahlt: Optional[Betrag] = attr.ib(
+        default=None, validator=attr.validators.optional(attr.validators.instance_of(Betrag))
+    )
+    #: Gesamtrabatt auf den Bruttobetrag
+    rabatt_brutto: Optional[Betrag] = attr.ib(
+        default=None, validator=attr.validators.optional(attr.validators.instance_of(Betrag))
+    )
+    steuerbetraege: Optional[List[Steuerbetrag]] = attr.ib(
+        default=None,
+        validator=attr.validators.optional(
+            attr.validators.deep_iterable(
+                member_validator=attr.validators.instance_of(Steuerbetrag),
+                iterable_validator=attr.validators.instance_of(list),
+            )
+        ),
+    )
+    """
+    Eine Liste mit Steuerbeträgen pro Steuerkennzeichen/Steuersatz;
+    die Summe dieser Beträge ergibt den Wert für gesamtsteuer.
+    """
+
+
+class RechnungSchema(GeschaeftsobjektSchema):
+    """
+    Schema for de-/serialization of Rechnung
+    """
+
+    class_name = Rechnung  # type:ignore[assignment]
+
+    # required attributes
+    storno = fields.Bool()
+    rechnungsnummer = fields.Str()
+    rechnungsdatum = fields.DateTime()
+    faelligkeitsdatum = fields.DateTime()
+    rechnungstyp = EnumField(Rechnungstyp)
+    rechnungsperiode = fields.Nested(ZeitraumSchema)
+    rechnungsersteller = fields.Nested(GeschaeftspartnerSchema)
+    rechnungsempfaenger = fields.Nested(GeschaeftspartnerSchema)
+    gesamtnetto = fields.Nested(BetragSchema)
+    gesamtsteuer = fields.Nested(BetragSchema)
+    gesamtbrutto = fields.Nested(BetragSchema)
+    zuzahlen = fields.Nested(BetragSchema)
+    rechnungspositionen = fields.List(fields.Nested(RechnungspositionSchema))
+
+    # optional attributes
+    rechnungstitel = fields.Str(allow_none=True)
+    rechnungsstatus = EnumField(Rechnungsstatus, allow_none=True)
+    original_rechnungsnummer = fields.Str(allow_none=True)
+    vorausgezahlt = fields.Nested(BetragSchema, allow_none=True)
+    rabatt_brutto = fields.Nested(BetragSchema, allow_none=True)
+    steuerbetraege = fields.List(fields.Nested(SteuerbetragSchema), allow_none=True)


Test patch:
diff --git a/tests/test_rechnung.py b/tests/test_rechnung.py
new file mode 100644
index 000000000..de4fcafb9
--- /dev/null
+++ b/tests/test_rechnung.py
@@ -0,0 +1,124 @@
+from datetime import datetime, timezone
+from decimal import Decimal
+
+import pytest  # type:ignore[import]
+
+from bo4e.bo.geschaeftspartner import Geschaeftspartner
+from bo4e.bo.rechnung import Rechnung, RechnungSchema
+from bo4e.com.betrag import Betrag
+from bo4e.com.rechnungsposition import Rechnungsposition
+from bo4e.com.steuerbetrag import Steuerbetrag
+from bo4e.com.zeitraum import Zeitraum
+from bo4e.enum.geschaeftspartnerrolle import Geschaeftspartnerrolle
+from bo4e.enum.rechnungsstatus import Rechnungsstatus
+from bo4e.enum.rechnungstyp import Rechnungstyp
+from bo4e.enum.steuerkennzeichen import Steuerkennzeichen
+from bo4e.enum.waehrungscode import Waehrungscode
+from bo4e.enum.zeiteinheit import Zeiteinheit
+from tests.serialization_helper import assert_serialization_roundtrip  # type:ignore[import]
+from tests.test_betrag import example_betrag  # type:ignore[import]
+from tests.test_menge import example_menge  # type:ignore[import]
+from tests.test_preis import example_preis  # type:ignore[import]
+from tests.test_steuerbetrag import example_steuerbetrag  # type:ignore[import]
+
+_rechnungsersteller = Geschaeftspartner(
+    name1="Joachim",
+    gewerbekennzeichnung=False,
+    geschaeftspartnerrolle=[Geschaeftspartnerrolle.LIEFERANT],
+)
+
+_rechnungsempfaenger = Geschaeftspartner(
+    name1="Helga",
+    gewerbekennzeichnung=False,
+    geschaeftspartnerrolle=[Geschaeftspartnerrolle.KUNDE],
+)
+
+
+class TestRechnung:
+    @pytest.mark.parametrize(
+        "rechnung",
+        [
+            pytest.param(
+                Rechnung(
+                    rechnungstitel="Hüpfburg",
+                    rechnungsstatus=Rechnungsstatus.UNGEPRUEFT,
+                    storno=True,
+                    rechnungsnummer="202201211701",
+                    rechnungsdatum=datetime.today(),
+                    faelligkeitsdatum=datetime.today(),
+                    rechnungstyp=Rechnungstyp.ENDKUNDENRECHNUNG,
+                    original_rechnungsnummer="RE-2022-01-21_1701",
+                    rechnungsperiode=Zeitraum(einheit=Zeiteinheit.TAG, dauer=Decimal(21)),
+                    rechnungsersteller=_rechnungsersteller,
+                    rechnungsempfaenger=_rechnungsempfaenger,
+                    gesamtnetto=Betrag(wert=Decimal(12.5), waehrung=Waehrungscode.EUR),
+                    gesamtsteuer=Betrag(wert=Decimal(12.5), waehrung=Waehrungscode.EUR),
+                    gesamtbrutto=Betrag(wert=Decimal(12.5), waehrung=Waehrungscode.EUR),
+                    vorausgezahlt=Betrag(wert=Decimal(12.5), waehrung=Waehrungscode.EUR),
+                    rabatt_brutto=Betrag(wert=Decimal(12.5), waehrung=Waehrungscode.EUR),
+                    zuzahlen=Betrag(wert=Decimal(12.5), waehrung=Waehrungscode.EUR),
+                    steuerbetraege=[
+                        Steuerbetrag(
+                            steuerkennzeichen=Steuerkennzeichen.UST_19,
+                            basiswert=Decimal(20.25),
+                            waehrung=Waehrungscode.EUR,
+                            steuerwert=Decimal(10.5),
+                        )
+                    ],
+                    rechnungspositionen=[
+                        Rechnungsposition(
+                            positionsnummer=1,
+                            lieferung_von=datetime(2021, 3, 15, tzinfo=timezone.utc),
+                            lieferung_bis=datetime(2022, 3, 15, tzinfo=timezone.utc),
+                            positionstext="Besonders wertvolle Rechnungsposition",
+                            positions_menge=example_menge,
+                            einzelpreis=example_preis,
+                            teilsumme_netto=example_betrag,
+                            teilsumme_steuer=example_steuerbetrag,
+                        )
+                    ],
+                ),
+                id="maximal attributes",
+            ),
+            pytest.param(
+                Rechnung(
+                    storno=True,
+                    rechnungsnummer="202201211701",
+                    rechnungsdatum=datetime.today(),
+                    faelligkeitsdatum=datetime.today(),
+                    rechnungstyp=Rechnungstyp.ENDKUNDENRECHNUNG,
+                    original_rechnungsnummer="RE-2022-01-21_1701",
+                    rechnungsperiode=Zeitraum(einheit=Zeiteinheit.TAG, dauer=Decimal(21)),
+                    rechnungsersteller=_rechnungsersteller,
+                    rechnungsempfaenger=_rechnungsempfaenger,
+                    gesamtnetto=Betrag(wert=Decimal(12.5), waehrung=Waehrungscode.EUR),
+                    gesamtsteuer=Betrag(wert=Decimal(12.5), waehrung=Waehrungscode.EUR),
+                    gesamtbrutto=Betrag(wert=Decimal(12.5), waehrung=Waehrungscode.EUR),
+                    zuzahlen=Betrag(wert=Decimal(12.5), waehrung=Waehrungscode.EUR),
+                    rechnungspositionen=[
+                        Rechnungsposition(
+                            positionsnummer=1,
+                            lieferung_von=datetime(2021, 3, 15, tzinfo=timezone.utc),
+                            lieferung_bis=datetime(2022, 3, 15, tzinfo=timezone.utc),
+                            positionstext="Besonders wertvolle Rechnungsposition",
+                            positions_menge=example_menge,
+                            einzelpreis=example_preis,
+                            teilsumme_netto=example_betrag,
+                            teilsumme_steuer=example_steuerbetrag,
+                        )
+                    ],
+                ),
+                id="minimal attributes",
+            ),
+        ],
+    )
+    def test_serialization_roundtrip(self, rechnung: Rechnung):
+        """
+        Test de-/serialisation
+        """
+        assert_serialization_roundtrip(rechnung, RechnungSchema())
+
+    def test_missing_required_attribute(self):
+        with pytest.raises(TypeError) as excinfo:
+            _ = Rechnung()
+        assert "missing 13 required" in str(excinfo.value)  # 13 from rechnung