Welcome to libestg3b’s documentation!

CI Status Documentation Status test coverage PyPI

§ 3b of the German Einkommensteuergesetz (EStG) defines the premiums for work on weekends, holidays and special days, like new year. This library takes a list of work hours / shifts (e.g. on 2018-06-03 from 19:00 until 03:00) and returns the premium factor (e.g. 1.5) as well as the relevant time-spans.

As noted in the license, this software is provided without any warranty or guarantee for correctness.

§ 3b des Deutschen Einkommensteuergesetzes (EStG) definiert die Höhe der steuerfreien Zuschläge für Arbeit in der Nacht, an Sonntagen, Feiertagen sowie besonderen Tagen wie Neujahr. Diese Library errechnet aus einer Liste von Arbeitszeiten die Höhe der maximalen Zuschläge.

Diese Software wird, wie in der Lizenz angegeben, ohne Gewähr und Garantie auf Richtigkeit bereitgestellt.

Installation

Install libestg3b via pip:

$ pip install libestg3b

Usage

>>> import datetime as DT
>>> from libestg3b import EStG3b
>>> e = EStG3b('DE')()
>>> e.calculate_shift([datetime(2018, 9, 16, 20), datetime(2018, 9, 17, 2)])
[
    Match(
        start=datetime.datetime(2018, 9, 16, 19, 0),
        end=datetime.datetime(2018, 9, 16, 20, 0),
        rules={<Rule: Sonntagsarbeit>}
    ),
    Match(
        start=datetime.datetime(2018, 9, 16, 20, 0),
        end=datetime.datetime(2018, 9, 17, 0, 0),
        rules={<Rule: Sonntagsarbeit>, <Rule: Nachtarbeit 20:00-06:00>}
    ),
    Match(
        start=datetime.datetime(2018, 9, 17, 0, 0),
        end=datetime.datetime(2018, 9, 17, 2, 0),
        rules={<Rule: Sonntagsarbeit (Montag)>, <Rule: Nachtarbeit 00:00-04:00 (Folgetag)>}
    ),
]

Development

Setup

Using python 3.6, do the following:

$ virtualenv venv --python=python3.6
$ pip install -e ".[dev]"

Usual Tasks

  • make test: run tests (use tox or py.test directly to supply flags like -k)
  • make lint: run pylava and friends
  • make fixlint: sort imports correctly

Releasing a new version

Assuming you have been handed the required credentials, a new version can be released as follows.

  1. adapt the version in setup.py, according to semver.
  2. commit this change as Version 1.2.3
  3. tag the resulting commit as v1.2.3
  4. push the new tag as well as the master branch
  5. update the package on PyPI:
$ rm dist/*
$ python setup.py sdist bdist_wheel
$ twine upload dist/*

Prerequisites

This library is currently python 3.6+. If you would like to use this library with a lower python version, please open an issue. We’re happy to change things around.

Versioning

New version numbers are assigned following semver. All 0.x.y versions are tested and usable, but do not have a stable public interface.

A version 1.0 will be released, once we deem the library stable.

License

All code in this repository is licensed under the MIT license.

using libestg3b

  1. have people work on weekends
  2. have them write down when exactly
  3. pipe information into libestg3b
  4. have it tell you which shifts are relevant for extra pay, and how much
  5. ???
  6. PROFIT!

Prerequisites

To use this library, you need some kind of a system to track how many hours were spent working. In some cases, this may be a simple CSV file, a web tool to track work hours or an export from your fancy SAP application. In any case, you need a list of hours worked, each with a start and end date and time.

In this example we will use the easiest source: a text file, work.txt:

2018-09-22T18:00:00 2018-09-22T20:00:00
2018-09-23T02:00:00 2018-09-23T03:00:00
2018-09-24T22:00:00 2018-09-25T01:00:00

So, we’ve got data. Now make it into a list of datetime objects:

import dateutil.parser

def parse_work():
    with open('work.txt') as f:
      return (
          (dateutil.parser.parse(l[0]), dateutil.parser.parse(l[1]))
          for l in map(lambda l: l.split(' '), f.readlines())
      )

def main():
    shifts = parse_work()

if __name__ == '__main__':
    main()

Great! That’s all we need to get libestg3b running.

Turning shifts into bonuses

The main functionality of libestg3b is to turn a shift into a list of matching rules defined by law. For example, the German law mandates, that work on sundays can be paid 50% more; work in the night receives a bonus of up to 40%, depending on the exact times.

Let’s use the shift data we’ve got and see what calculate_shifts() can tell us about it:

...

matches = libestg3b.EStG3b('DE')().calculate_shifts(shifts)

# for each input shift, calculate_shifts returned one object ...
for match in matches:
    print(match)

When we run the script with the new code added in the last example, we get:

<Match 2018-09-22T18:00:00~2018-09-22T20:00:00, None, add=0, multiply=0>
<Match 2018-09-23T02:00:00~2018-09-23T03:00:00, DE_NIGHT+DE_SUNDAY, add=0, multiply=0.75>
<Match 2018-09-24T22:00:00~2018-09-25T00:00:00, DE_NIGHT, add=0, multiply=0.25>
<Match 2018-09-25T00:00:00~2018-09-25T01:00:00, DE_NIGHT_START_YESTERDAY, add=0, multiply=0.4>

As you can see, our three shifts turned into four matches. Let’s talk about what happened here:

  1. 09-22 18:00 to 09-22 20:00
    Probably the most boring example there is. Not a single rule matched.
  2. 09-23 02:00 to 09-23 03:00
    A combination of two rules: DE_NIGHT (25%) and DE_SUNDAY (50%). Since the law allows combination in this case, we end up with a total bonus of 75%.
  3. 09-24 22:00 to 09-25T01:00
    This one is fun! Again, two matching rules were found, but each of them is not able to cover the whole shift. Therefore we get two Match objects: one for the first two hours (DE_NIGHT, 25%) and one just for the last hour (DE_NIGHT_START_YESTERDAY, 40%).

Note that multiply values are given as 0.xx, not 1.xx as is common. This is to enable simply combination of multiple rules: 1.2 + 1.2 equals 2.4, which is too high, but 0.2 + 0.2 equals the correct 0.4.

Making money out of matches

The Match object only tells us how to modify the base salary people get. While this is useful information, accounting likes to get absolute numbers. As you can see in the output above, rules can modify the salary in two ways: add a fixed amount (5€ more), multiply by some factor (20% more). Since our example is based on German law, which only works with percentages, we can simplify our code.

To determine the amount of money to be payed out, have a look at the bonus_* and minutes attributes. The first one tells us how much to increase the base salary, the 2nd one tells us how much time was actually relevant.

import libestg3b

def main():
    shifts = parse_work()
    matches = libestg3b.EStG3b('DE')().calculate_shifts(shifts)
    base_salary = Decimal(25) / 60
    total = Decimal(0)

    for match in matches:
        bonus = match.bonus_multiply + 1
        eur = base_salary * match.minutes * bonus
        total = total + eur

        print(match)
        print(f'({base_salary}€ * {bonus:.2f}) * {match.minutes}m = {eur: 2.2f}€')

    print(f'\nTotal: {total:.2f}€')

… and when we run it:

<Match 2018-09-22T18:00:00~2018-09-22T20:00:00, None, add=0, multiply=0>
(25€ * 1.00) * 120m =  50.00€
<Match 2018-09-23T02:00:00~2018-09-23T03:00:00, DE_NIGHT+DE_SUNDAY, add=0, multiply=0.75>
(25€ * 1.75) * 60m =  43.75€
<Match 2018-09-24T22:00:00~2018-09-25T00:00:00, DE_NIGHT, add=0, multiply=0.25>
(25€ * 1.25) * 120m =  62.50€
<Match 2018-09-25T00:00:00~2018-09-25T01:00:00, DE_NIGHT_START_YESTERDAY, add=0, multiply=0.4>
(25€ * 1.40) * 60m =  35.00€

Total: 191.25€

That’s it, bascially. Depending on your exact needs, you can now put this code into a CSV, throw it at some API or just print it out.

Have fun!

adding rules

Most companies just follow the law when it comes to paying bonuses for work on weekends. Libestg3b comes with a standard set of rules to cover that use case. It is, howerver, also possible to extend the Rules to match your situation. This enables you to define special situations like “12-12 is our annual christmas party, pay 100% extra for any shifts during that”.

Defining it

Rules are implemented using Rule objects. They contain all neccesary information like the rules name, when to apply it and what do to, when it does.

from decimal import Decimal
from libestg3b.rule import Rule

r = Rule(
    "CHRISTMAS_PARTY",
    "Annual company christmas party",
    lambda minute, start, holidays: minute.month == 12 and minute.day == 12,
    multiply=Decimal(1),
)

We’ve got a rule! Most of this should be pretty easy to understand, but the lambda in there can raise some questions, so let’s go over it: The function you pass into the constructor is the actual implementation of your rule. It defines when to apply it. To make sure it’s able to do its job, a couple of data points are passed into it:

  • minute (datetime)

Also refer to libestg3b.rule.Rule.match() for further information and examples.

Boiling it down

Writing a lambda function for each rule you want to write is fun, but generates a lot of boilerplate code. Since many rules are quite repetitive (“match on some date”, “match after some time”, …), libestg3b comes with a couple of helpers to save you some time:

  • DayRule: match on a given month/day combination (e.g. YYYY-03-28)
  • DayTimeRule: like DayRule, but also require the shift to be after a certain time (e.g. 14:00).

Documentation and examples on these classes can be found in the respective class docs (follow the link, alice!). To clear things up a bit, have a look at the following example on how to shorten our CHRISTMAS_PARTY rule using the DayRule class:

from decimal import Decimal
from libestg3b.rule import DayRule

r = DayRule("CHRISTMAS_PARTY", 12, 12, multiply=Decimal(1))

Except for the imports, we can now even fit it into one line without feeling bad.

Plugging it in

Feel floating rule objects are great, but they don’t do much. To convince the library to actually use your rules to match shifts, we need to tell it about them:

from decimal import Decimal
from libestg3b import EStG3b
from libestg3b.rule import DayRule, RuleGroup

est = EStG3b("DE")(add_rules=[
    RuleGroup(
        "GRP_CUSTOM",
        "Rules special to our company",
        [DayRule("CHRISTMAS_PARTY", 12, 12, multiply=Decimal(1))],
    )
])

You’ll quickly notice a new thing here: Groups.

A libestg3b.rule.RuleGroup is a set of rules of which only one may ever match. A pratical example of why this might be useful is outlined in German law: there is night work (+25%), work on sundays (+50%) and work on holidays (+125%). While work during sunday nights allows combining the rules to yield +175%, work on sundays, which happen to be a holiday, only allows one of the rules to be applied, resulting in +125%. In case two or more rules match, group chooses the one with the highest bonus and discards all other matches.

Since all rules need to be in a group, we just make up a new one (GRP_CUSTOM) with nothing in it except for our special rule. This allows it to be matched in addition to any other rules already predefined by law.

Running it

We’ve got a rule, we’ve told the library about it, let’s see, if it actually works. Make up a shift from 12-12 19:00 until 01:00 the next day, plug it into calculate_shift as outlined in the first guide and run it:

...

import datetime as DT

m = est.calculate_shift([DT.datetime(2018, 12, 12, 19), DT.datetime(2018, 12, 13, 1)])
print(m)
[
    <Match 2018-12-12T19:00~2018-12-12T20:00, CHRISTMAS_PARTY, add=0, multiply=1>,
    <Match 2018-12-12T20:00~2018-12-13T00:00, CHRISTMAS_PARTY+DE_NIGHT, add=0, multiply=1.25>,
    <Match 2018-12-13T00:00~2018-12-13T01:00, DE_NIGHT_START_YESTERDAY, add=0, multiply=0.4>
]

As you can see, our rule worked just as intended. In addition to the predefined DE_NIGHT rules, there is now also a match for CHRISTMAS_PARTY during the relevant times.

Happy matching!

libestg3b

libestg3b.EStG3b(country)[source]

Get the implementation class for the given country.

Parameters:country (str) – ISO short code of the desired country, e.g. "DE"
Return type:Type[EStG3bBase]
libestg3b.EStG3bs()[source]

Get a list containing implementation classes for all implemented countries.

Return type:List[Type[EStG3bBase]]
class libestg3b.EStG3bBase(country, groups, add_rules=None, replace_rules=None)[source]

Bases: object

calculate_shift(shift)[source]

Turn a shift into a number of matches, containing the relevant rules (if any), which can be used to calculate the appropriate high of bonus payments.

>>> import datetime as DT
>>> from libestg3b import EStG3b
>>> e = EStG3b("DE")
>>> e.calculate_shift([DT.datetime(2018, 12, 24, 13), DT.datetime(2018, 12, 25, 2)])
[
    Match(start=datetime.datetime(2018, 12, 24, 13, 0), end=datetime.datetime(2018, 12, 24, 14, 0), rules=set(
    )),
    Match(start=datetime.datetime(2018, 12, 24, 14, 0), end=datetime.datetime(2018, 12, 24, 20, 0), rules={
        <Rule: DE_HEILIGABEND YYYY-12-24 14:00+>
    }),
    Match(start=datetime.datetime(2018, 12, 24, 20, 0), end=datetime.datetime(2018, 12, 25, 0, 0), rules={
        <Rule: DE_HEILIGABEND YYYY-12-24 14:00+>,
        <Rule: DE_NIGHT Nachtarbeit 20:00-06:00>
    }),
    Match(start=datetime.datetime(2018, 12, 25, 0, 0), end=datetime.datetime(2018, 12, 25, 2, 0), rules={
        <Rule: DE_WEIHNACHTSFEIERTAG_1 YYYY-12-25>,
        <Rule: DE_NIGHT_START_YESTERDAY Nachtarbeit 00:00-04:00 (Folgetag)>
    })
]
Parameters:shift (Tuple[datetime, datetime]) – a (starttime, endtime) tuple. Describes a shift started and starttime (inclusive) and ending at endtime (exclusive).
Return type:List[Match]
calculate_shifts(shifts)[source]

Behaves similar to calculate_shift(), but takes a list of shifts and returns a list of matches. It also merges any shifts that overlap, resulting in a clean list of matches.

Parameters:shifts (List[Tuple[datetime, datetime]]) –
Return type:Iterator[Match]
class libestg3b.Match(start, end, rules)[source]

Bases: object

The final result of the calculation process. It links time worked to additional payments (or the information that none are relevant).

Parameters:
  • start (datetime) – the (inclusive) time this shift part starts at
  • end (datetime) – the (exclusive) time this shift part ends at
  • rules (Set[Rule]) – all the relevant Rule instances. May be empty to indicate, that no match has been found.
rules_str

a human-readable representation of all the rules matched, e.g. DE_NIGHT+DE_SUNDAY

Return type:str
bonus_multiply

the height of the bonus, as a factor to add e.g. Decimal(0.2) => 20%.

Return type:Decimal
bonus_add

the total amount of monetary units to add as a bonus, e.g. Decimal(5) => 5€.

Return type:Decimal
minutes

the number of minutes this Match covers, e.g. Decimal(180) => 3h.

Return type:Decimal

libestg3b.rule

class libestg3b.rule.Rule(slug, description, impl, *, multiply=None, add=None, tests=[])[source]

Bases: object

Defines a situation in which an employee might receive extra pay, e.g. “on 24th of december, pay 50% more”.

Parameters:
  • slug (str) – a machine and human-ish readable name for this rule (see below).
  • description (str) – a human readable short-form description
  • impl (Callable[…, bool]) – actual matching function, accepting one, two or three parameters.
  • multiply (Optional[Decimal]) – heigth of the bonus, as a factor. Supplying 0.25 results in a pay increase of 25%.
  • add (Optional[Decimal]) – heigth of the bonus, as an absolute currency value. Either multiply or add must be given, but not both.

The actual logic of a rule is passed in via the impl parameter. This function must accept 1-3 arguments: minute, start and holidays. Refer to match() for the meaning of those parameters.

match(minute, start, holidays)[source]

For matching, a shift must be split into its individual minutes. Each of these minutes is then passed into this method. Additionally the very first minute is provided, to enable rules like (worked after midnight, but started before).

>>> from decimal import Decimal
>>> import datetime as DT
>>> from libestg3b.rule import Rule
>>> m = Rule("NIGHT", "Nachtarbeit", lambda m, f: m.hour >= 20, multiply=Decimal(2))
# Shift started at 2018-02-02 21:00 and this is the first minute: match!
>>> m.match(DT.datetime(2018, 2, 2, 21), DT.datetime(2018, 2, 2, 21), None)
True
# Shift started at 2018-02-02 20:00 and 21:00 is checked: match!
>>> m.match(DT.datetime(2018, 2, 2, 21), DT.datetime(2018, 2, 2, 20), None)
True
# Shift started at 2018-02-02 18:00 and 19:00 is checked: no match
>>> m.match(DT.datetime(2018, 2, 2, 19), DT.datetime(2018, 2, 2, 18), None)
False
# Shift started at 2018-02-02 23:00 and 01:00 on the following day is checked
# even though the start of this shift is within the timeframe "after 21:00",
# the checked minute is not, so we don't match.
>>> m.match(DT.datetime(2018, 2, 3, 1), DT.datetime(2018, 2, 2, 23), None)
False
Parameters:
  • minute (datetime) – current minute to be matched
  • start (datetime) – very fist minute in this shift
  • holidays (HolidayBase) – holidays in the currently active country (see python-holidays)
Return type:

bool

class libestg3b.rule.DayRule(slug, month, day, **kwargs)[source]

Bases: libestg3b.rule.Rule

Match, if the given minute is within the given day. This can be useful to increase pay on days, which are not official holidays, but still get a special treatment in the law (for example: 31th of December in Germany).

>>> from decimal import Decimal
>>> import datetime as DT
>>> from libestg3b.rule import DayRule
>>> m = DayRule("Helloween", 10, 31, multiply=Decimal("2"))
>>> m
<Rule: Helloween YYYY-10-31>
>>> m.match(DT.datetime(2018, 10, 31, 13), DT.datetime(2018, 10, 31, 12), None)
True
>>> m.match(DT.datetime(2018, 10, 30, 13), DT.datetime(2018, 10, 30, 12), None)
False
Parameters:
  • slug (str) – machine-readable name of this rule, see Rule
  • month (int) – only match, if shift is within this month, counted from 1 = January
  • day (int) – only match, if shift is on this day, counted from 1

Additionally all keyword arguments defined for Rule can be used.

class libestg3b.rule.DayTimeRule(slug, month, day, hour, **kwargs)[source]

Bases: libestg3b.rule.Rule

Like DayRule, but additionally require the shift to be after a certain time.

>>> from decimal import Decimal
>>> import datetime as DT
>>> from libestg3b.rule import DayTimeRule
>>> m = DayTimeRule("NEWYEARSEVE", 12, 31, 14, multiply=Decimal("1"))
>>> m
<Rule: NEWYEARSEVE YYYY-12-31 14:00+>
>>> m.match(DT.datetime(2018, 12, 31, 13), DT.datetime(2018,12, 31, 13), None)
False
>>> m.match(DT.datetime(2018, 12, 31, 14), DT.datetime(2018,12, 31, 14), None)
True
Parameters:
  • slug (str) – machine-readable name of this rule, see Rule
  • month (int) – only match, if shift is within this month, counted from 1 = January
  • day (int) – only match, if shift is on this day, counted from 1
  • hour (int) – only match, if shift is after or in this hour. Supplying 14 results in 14:00 to 24:00 to be matched.

Additionally all keyword arguments defined for Rule can be used.

class libestg3b.rule.RuleGroup(slug, description, rules)[source]

Bases: object

A collection of similar Rule instances. When the group is evaluated, only the highest matching machter is returned.

Parameters:
  • slug (str) – a machine and human-ish readable name for this rule, must not change.
  • description (str) – a short, human-readable text, explaining why the given rules are grouped together.
  • rules (Iterable[Rule]) – the initial set of rules.
append(rule, replace=False)[source]
Parameters:
  • rule (Rule) – rule to add; it must not yet exist in the group.
  • replace (bool) – if rule duplicates an existing one, overwrite it.
Return type:

None

match(minute, start, holidays)[source]

Evaluate this group. The given shift is tested using each of the stored rules. The rule with the highest bonus is the returned. If not a single one matches, None is returned.

This method is normally used by libestg3b.EStG3b, but you can use it to implement more complex scenarios yourself.

Parameters:
  • minute (datetime) – minute to evaluate (see libestgb3.EStG3b)
  • start (datetime) – the first minute in this shift (see libestgb3.EStG3b)
Return type:

Optional[Rule]

extend(rules, replace=False)[source]

Add the given rules to this group.

Parameters:
  • rules (Iterable[Rule]) –
  • replace (bool) – if one of the given rule duplicates an existing one, overwrite it instead of raising an exception.
Return type:

None

libestg3b.countries

class libestg3b.countries.EStG3bGermany(**kwargs)[source]

Bases: libestg3b.estg3b.EStG3bBase

aliases = ['GERMANY', 'DE']