diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 00c3331..ee405ac 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -22,18 +22,18 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install flake8 pytest pytest-cov + pip install ruff pytest pytest-cov if [ -f requirements.txt ]; then pip install -r requirements.txt; fi if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi - name: set pythonpath run: | echo "PYTHONPATH=home/runner/work/" >> $GITHUB_ENV - - name: Lint with flake8 + - name: Lint with ruff run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + ruff check . + - name: Format check with ruff + run: | + ruff format --check . - name: Test with pytest run: | python -m pytest --import-mode=append test/ --cov=netto test/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..300a9f0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,12 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.8.4 + hooks: + # Run the linter. + - id: ruff + args: [--fix, --unsafe-fixes] + # Run the formatter. + - id: ruff-format diff --git a/CHANGELOG.md b/CHANGELOG.md index f9c58b9..e9d85c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0a3] - 2025-11-15 + +### Added +- **Pre-commit hooks**: Added `.pre-commit-config.yaml` for automatic code quality checks + - Runs ruff linting with auto-fix before each commit + - Runs ruff formatting before each commit + - Prevents CI failures by catching issues locally + - Added `pre-commit` to development dependencies + +### Changed +- **Code Quality Tools**: Migrated from Black, isort, and Flake8 to Ruff + - Single, faster tool for formatting and linting + - Configuration in `pyproject.toml` + - Line length set to 88 (Black-compatible, industry standard) + - Updated CI workflow to use Ruff + - Updated development dependencies in `requirements-dev.txt` + - Applied ruff formatting and linting fixes across entire codebase +- **README.md**: Completely redesigned for better usability + - Added comprehensive feature list with emojis + - Added installation instructions + - Added quick start examples (basic, custom config, with deductibles) + - Added configuration reference table + - Added development setup instructions + - Added code quality commands using Ruff + - Updated badge from "Code style: black" to Ruff badge +- **CLAUDE.md**: Updated project documentation + - Updated project structure to reflect `data/` directory instead of `const.py` + - Updated Code Style section to mention Ruff instead of Black/Flake8 + - Updated Testing section to reflect pytest migration (completed) + - Updated Linting and Formatting section with Ruff commands + - Updated Updating Tax Data section to reflect new JSON-based workflow + - Updated CI/CD section to mention Ruff + - Moved completed tasks to "Completed Tasks" section + - Updated Tax Year Support Matrix (2023-2025 now fully supported) + - Updated last modified date to 2025-11-15 and version to 1.1 + +### Removed +- **Development Dependencies**: Removed Black, isort, and Twine from `requirements-dev.txt` + - Replaced with Ruff for formatting and linting + - Twine removed as it's not needed for modern PyPI publishing workflow + ## [0.2.0] - 2025-11-15 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 2f41f0f..14fceb7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,11 +27,17 @@ netto/ │ ├── __init__.py # Package initialization │ ├── main.py # Main API (calc_netto, calc_inverse_netto) │ ├── config.py # TaxConfig dataclass -│ ├── const.py # Tax curves and social security data (⚠️ needs refactoring) +│ ├── data_loader.py # Data loader with Pydantic validation │ ├── taxes_income.py # Income tax calculations │ ├── taxes_other.py # Solidarity and church tax │ └── social_security.py # Social security calculations -├── test/ # Test suite (unittest) +├── data/ # Tax data (JSON files with validation) +│ ├── tax_curves/ # Income tax brackets by year +│ ├── social_security/ # Social security rates by year +│ ├── soli/ # Solidarity tax parameters +│ ├── pension_factors/ # Pension correction factors +│ └── README.md # Data structure documentation +├── test/ # Test suite (pytest) ├── docs/ # Sphinx documentation ├── examples/ # Usage examples └── .github/workflows/ # CI/CD pipelines @@ -48,21 +54,21 @@ netto/ - `church_tax`: Church tax rate (default: 0.09, set to 0.0 for none) - Includes validation in `__post_init__` -#### 2. Tax Data (`const.py`) -**⚠️ HIGH PRIORITY REFACTORING NEEDED** +#### 2. Tax Data (`data_loader.py`) +**✅ REFACTORED TO STRUCTURED DATA** -Contains four main data structures: -- `__tax_curve`: Progressive income tax brackets by year -- `__social_security_curve`: Social security limits and rates -- `__soli_curve`: Solidarity tax parameters -- `__correction_factor_pensions`: Pension deduction factors +Contains data loader with Pydantic validation for: +- `tax_curve`: Progressive income tax brackets by year +- `social_security_curve`: Social security limits and rates +- `soli_curve`: Solidarity tax parameters +- `pension_factors`: Pension deduction factors -**Current Issues**: -- Hardcoded Python dictionaries -- Difficult to maintain and audit -- No schema validation -- 2023-2025 data has incomplete `const` values (set to None) -- Cannot easily extend to new years +**Benefits of Current Structure**: +- JSON files in `data/` directory for easy editing +- Pydantic schema validation ensures data integrity +- Easy to audit changes via git diffs +- Simple to extend to new years (just add new JSON files) +- Clear separation between code and data #### 3. Main API (`main.py`) @@ -104,14 +110,15 @@ Contains four main data structures: ## Development Guidelines ### Code Style -- **Formatter**: Black (line length: 127) -- **Linter**: Flake8 +- **Formatter & Linter**: Ruff (line length: 88) + - Replaces Black, isort, and Flake8 with a single fast tool + - Uses Black-compatible 88 character line length (industry standard) + - Configuration in `pyproject.toml` - **Type Hints**: Use type hints for all public functions - **Docstrings**: NumPy-style docstrings with Parameters, Returns, Examples ### Testing -**Current**: unittest framework -**Recommended**: Migrate to pytest +**Framework**: pytest (migrated from unittest) **Test Coverage**: - Target: >80% code coverage @@ -129,45 +136,13 @@ Contains four main data structures: ## Release 0.2.0 - Preparation Tasks -### Immediate Tasks - -#### 1. Fix ReadTheDocs Build Action (High Priority) -**Status**: ⚠️ Needs Investigation - -**Issue**: ReadTheDocs build may be failing or not configured - -**Investigation Steps**: -1. Check if `.readthedocs.yml` exists (currently missing) -2. Verify docs build locally: `cd docs && make html` -3. Check Sphinx configuration in `docs/conf.py` -4. Verify all dependencies in `docs/requirements.txt` - -**Recommended Action**: -Create `.readthedocs.yml` in project root: -```yaml -version: 2 - -build: - os: ubuntu-22.04 - tools: - python: "3.12" - -sphinx: - configuration: docs/conf.py - -python: - install: - - requirements: docs/requirements.txt - - method: pip - path: . -``` +### Remaining Tasks -#### 2. Add/Check Tax Codes for 2024-2027 (High Priority) +#### 1. Add/Check Tax Codes for 2024-2027 (High Priority) **Status**: ⚠️ Incomplete Data **Current State**: -- ✅ 2018-2022: Complete with all constants -- ⚠️ 2023-2025: Steps and rates present, but `const` values are None +- ✅ 2018-2025: Complete with all constants - ❌ 2026-2027: Not implemented **Required Data Sources**: @@ -176,11 +151,11 @@ python: - [Social Security Rates](https://www.lohn-info.de/sozialversicherungsbeitraege2024.html) **Tasks**: -1. Calculate and fill in missing `const` values for 2023-2025 tax curves -2. Verify social security rates for 2024-2025 -3. Research and add preliminary data for 2026-2027 (if available) -4. Update `config.py` validation to support new years -5. Add tests for new years +1. Research and add preliminary data for 2026-2027 (if available from official sources) +2. Create JSON files in `data/` directory for 2026-2027 +3. Update `config.py` validation to support new years +4. Add tests for new years +5. Verify calculations against official BMF calculators **Tax Curve Constants Explanation**: The `const` values are polynomial coefficients used in German tax calculation: @@ -189,66 +164,7 @@ The `const` values are polynomial coefficients used in German tax calculation: - Bracket 2: [a, b, c] for first progression zone - Bracket 3: [a, b] for top tax rate -#### 3. Refactor const.py to Structured Data (Medium Priority) -**Status**: 📋 Planned - -**Problem**: -- Hard to update/maintain -- No schema validation -- Difficult to audit changes -- Can't easily extend to new years -- Python dictionaries are not ideal for data storage - -**Recommendation**: -Move to structured data files with validation - -**Proposed Structure**: -``` -data/ -├── tax_curves/ -│ ├── 2018.json -│ ├── 2019.json -│ ├── ... -│ └── 2025.json -├── social_security/ -│ ├── 2018.json -│ ├── ... -│ └── 2025.json -├── soli_curve.json -└── pension_factors.json -``` - -**Implementation Plan**: -1. Create JSON schema for validation (using jsonschema or pydantic) -2. Create data migration script to convert `const.py` to JSON files -3. Create data loader in `const.py` to read JSON files -4. Add validation layer to ensure data integrity -5. Create data update tooling (CLI or scripts) -6. Update documentation for data maintenance -7. Version control data separately with clear commit messages - -**Benefits**: -- Easy to review changes in PRs (diff JSON files) -- Can add schema validation -- Non-developers can update tax data -- Easier to automate data updates -- Better separation of code and data - -**Example Schema**: -```python -from pydantic import BaseModel, Field - -class TaxBracket(BaseModel): - step: float = Field(gt=0) - rate: float = Field(ge=0, le=1) - const: list[float] | None = None - -class TaxCurve(BaseModel): - year: int = Field(ge=2018, le=2030) - brackets: dict[int, TaxBracket] -``` - -#### 4. Improve Error Handling (Medium Priority) +#### 2. Improve Error Handling (Medium Priority) **Status**: 📋 Planned **Current Issues**: @@ -299,52 +215,38 @@ def calc_netto(salary: float, ...) -> float: - Consider more specific exception types - Add helpful error messages with valid ranges -#### 5. Migrate to Pytest (Low Priority) -**Status**: 📋 Nice to Have - -**Current**: Using unittest -**In dev-dependencies**: pytest is available - -**Benefits of pytest**: -- Simpler, more Pythonic test syntax -- Better fixtures and parametrization -- More informative failure messages -- Active development and plugin ecosystem -- Already used in CI workflow - -**Migration Example**: -```python -# Before (unittest) -class TestMain(unittest.TestCase): - def test_for_valid_main(self): - self.assertAlmostEqual( - main.calc_netto(30000, config=self.default_config), - 20554.38, - delta=1 - ) +### Completed Tasks -# After (pytest) -@pytest.mark.parametrize("salary,expected", [ - (30000, 20554.38), - (60000, 35796.68), - (90000, 49956.92), - (120000, 64965.08), -]) -def test_calc_netto(salary, expected, default_config): - assert abs(main.calc_netto(salary, config=default_config) - expected) < 1 -``` +The following tasks have been completed in v0.2.0: -**Migration Steps**: -1. Convert one test file as proof of concept -2. Create pytest fixtures for common configs -3. Use parametrize for data-driven tests -4. Convert remaining test files -5. Remove unittest imports -6. Update documentation +- ✅ **ReadTheDocs Configuration**: Added `.readthedocs.yml` for proper documentation building +- ✅ **Refactor const.py to Structured Data**: Migrated from hardcoded Python dictionaries to validated JSON files +- ✅ **Migrate to Pytest**: Converted test suite from unittest to pytest +- ✅ **Switch to Ruff**: Replaced Black, isort, and Flake8 with Ruff for faster linting and formatting ### Future Enhancements (Post 0.2.0) -From README TODO list: +**v0.3.0 - API Improvements:** +- [ ] **Overloaded parameters for helper functions** - Allow functions to accept either `salary` OR calculated values (e.g., `taxable_income`) + - Eliminates redundant parameter passing (currently need to pass `salary` multiple times) + - Example: `get_marginal_tax_rate()` could accept either `salary` or `taxable_income` + - Implementation approach: + ```python + def get_marginal_tax_rate( + salary: float | None = None, + taxable_income: float | None = None, + config: TaxConfig | None = None + ) -> float: + if salary is not None: + # Auto-calculate taxable_income from salary + deductible_ss = calc_deductible_social_security(salary, config) + taxable_income = calc_taxable_income(salary, deductible_ss) + return _calculate_rate(taxable_income, config) + ``` + - Benefits: Cleaner API, less verbose for common use cases, still allows granular control + - Apply to: `get_marginal_tax_rate()`, `calc_taxable_income()`, and similar functions + +**Other enhancements:** - [ ] Calculate support for children (Kindergeld/Kinderfreibetrag) - [ ] Implement correct pension deductible for East Germany - [ ] Add support for self-employed individuals @@ -356,10 +258,10 @@ From README TODO list: ### Running Tests ```bash -# Run all tests with unittest -python -m unittest discover test/ +# First, install package in editable mode (required for imports to work) +pip install -e . -# Run with pytest (preferred) +# Run with pytest (use python -m to ensure correct environment) python -m pytest test/ -v # Run with coverage @@ -367,8 +269,13 @@ python -m pytest --cov=netto test/ # Run specific test file python -m pytest test/test_main.py -v + +# Alternative: Use pytest's import mode (like CI does) +python -m pytest --import-mode=append test/ ``` +**Important**: Always use `python -m pytest` (not just `pytest`) to ensure tests run in the same Python environment where you installed the package. This avoids `ModuleNotFoundError` when using UV or other tool managers. + ### Building Documentation ```bash @@ -380,11 +287,14 @@ make html ### Linting and Formatting ```bash -# Format with black -black netto/ test/ examples/ +# Format with ruff +ruff format . + +# Lint with ruff +ruff check . -# Lint with flake8 -flake8 netto/ test/ --max-line-length=127 +# Fix linting issues automatically +ruff check --fix . ``` ### Local Development @@ -396,25 +306,35 @@ pip install -e . # Install with dev dependencies pip install -r requirements-dev.txt +# Set up pre-commit hooks (recommended) +pre-commit install + +# Run pre-commit on all files (optional, to test) +pre-commit run --all-files + # Run examples python examples/examples.py ``` -### Updating Tax Data +**Pre-commit hooks** automatically run ruff linting and formatting before each commit, preventing CI failures. This is highly recommended for all contributors. -**Current Process** (needs improvement): -1. Edit `netto/const.py` directly -2. Find relevant data from official sources -3. Update dictionaries with new year data -4. Update validation in `config.py` -5. Add tests for new year -6. Verify calculations against official calculators +### Updating Tax Data -**Future Process** (after refactoring): -1. Create new JSON file in `data/tax_curves/YEAR.json` -2. Run validation script -3. Auto-update supported years list -4. Run tests against official calculators +**Current Process**: +1. Create new JSON file in `data/tax_curves/YEAR.json` (copy and modify from previous year) +2. Create new JSON file in `data/social_security/YEAR.json` +3. Create new JSON file in `data/soli/YEAR.json` +4. Create new JSON file in `data/pension_factors/YEAR.json` +5. Find relevant data from official sources (BMF, lohn-info.de) +6. Update JSON files with new year data +7. Update validation in `config.py` to support the new year +8. Add tests for new year +9. Verify calculations against official BMF calculators + +**Data Validation**: +- All data is validated using Pydantic models in `data_loader.py` +- Invalid data will raise validation errors on import +- See `data/README.md` for detailed data structure documentation ### Release Process @@ -475,6 +395,10 @@ See `RELEASE.md` for detailed instructions. ### Git Workflow - **Main branch**: `master` (protected) - **Feature branches**: `claude/feature-name-SESSION_ID` +- **Pre-commit hooks**: HIGHLY RECOMMENDED - install with `pre-commit install` + - Automatically runs ruff linting and formatting before each commit + - Prevents CI failures by catching issues locally + - Ensures consistent code quality across all commits - **Commit messages**: Conventional commits format - `feat:` for new features - `fix:` for bug fixes @@ -482,11 +406,13 @@ See `RELEASE.md` for detailed instructions. - `refactor:` for refactoring - `test:` for tests - `chore:` for maintenance + - `style:` for code formatting/style changes ### CI/CD - **Build Workflow** (`workflow.yml`): Runs on every push - - Lint with flake8 - - Test on Python 3.10-3.14 + - Lint with ruff (`ruff check .`) + - Format check with ruff (`ruff format --check .`) + - Test on Python 3.10-3.14 with pytest - Upload coverage to Codecov - **PyPI Publishing**: - TestPyPI: Manual trigger via GitHub Actions @@ -553,13 +479,13 @@ net = calc_netto(50000, deductibles=2000, verbose=True, config=config) | 2020 | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Fully supported | | 2021 | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Fully supported | | 2022 | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Fully supported | -| 2023 | ⚠️ Partial (const=None) | ✅ Complete | ✅ Complete | ⚠️ Needs completion | -| 2024 | ⚠️ Partial (const=None) | ✅ Complete | ✅ Complete | ⚠️ Needs completion | -| 2025 | ⚠️ Partial (const=None) | ✅ Complete | ✅ Complete | ⚠️ Needs completion | -| 2026 | ❌ Not started | ❌ NotImplementedError | ❌ Missing | ❌ Planned | -| 2027 | ❌ Not started | ❌ Missing | ❌ Missing | ❌ Planned | +| 2023 | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Fully supported | +| 2024 | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Fully supported | +| 2025 | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Fully supported | +| 2026 | ❌ Not started | ❌ Not started | ❌ Not started | ❌ Planned | +| 2027 | ❌ Not started | ❌ Not started | ❌ Not started | ❌ Planned | --- -**Last Updated**: 2024-11-14 (for release 0.2.0 preparation) -**Document Version**: 1.0 +**Last Updated**: 2025-11-15 (for release 0.2.0a3) +**Document Version**: 1.1 diff --git a/README.md b/README.md index 585a0a9..5e3e65f 100644 --- a/README.md +++ b/README.md @@ -5,26 +5,214 @@ [![CI](https://github.com/0-k/netto/actions/workflows/workflow.yml/badge.svg)](https://github.com/0-k/netto/actions/workflows/workflow.yml) [![codecov](https://codecov.io/gh/0-k/netto/branch/master/graph/badge.svg)](https://codecov.io/gh/0-k/netto) [![License](https://img.shields.io/pypi/l/netto.svg)](LICENSE) -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -German income tax (Einkommensteuer) and social security (Sozialabgaben) calculator. +**netto** is a German income tax (Einkommensteuer) and social security (Sozialabgaben) calculator written in Python. It calculates net income from gross salary considering various tax brackets, social security contributions, solidarity tax (Solidaritätszuschlag), and optional church tax. -Currently tested against the following assumptions: -* Public health and pension insurance -* West-German pension deduction -* Optional: Church tax -* Optional: Married -* Supported tax years: 2018-2025 +## Features -## Sources +- **Calculate net income** from gross salary with `calc_netto()` +- **Calculate required gross salary** for desired net income with `calc_inverse_netto()` +- **Support for tax years 2018-2025** +- **Married couples support** (Ehegattensplitting - doubles tax brackets) +- **Children support** (affects nursing care insurance extra rate) +- **Optional church tax** (8-9%, configurable) +- **Public health and pension insurance** calculations +- **West-German pension deduction** (East German support planned) +- **Type-safe configuration** with Pydantic validation +- **Comprehensive documentation** on [ReadTheDocs](https://netto.readthedocs.io/) -* [German tax curve](https://www.bmf-steuerrechner.de/javax.faces.resource/2025_1_14_Tarifhistorie_Steuerrechner.pdf.xhtml) -* [Wage tax (Lohnsteuer)](https://www.bmf-steuerrechner.de/bl/bl2022/eingabeformbl2022.xhtml) -* [Social security deductable (Vorsorgepauschale)](https://www.lohn-info.de/vorsorgepauschale.html) -* [Social security rates](https://www.lohn-info.de/sozialversicherungsbeitraege2022.html) -* [Taxable income calculator](https://udo-brechtel.de/mathe/est_gsv/reverse_zve_brutto.htm) -* [Solidarity tax (Solidaritätszuschlag)](https://www.lohn-info.de/solizuschlag.html) +## Installation + +Install from PyPI using pip: + +```bash +pip install netto +``` + +Requires Python 3.10 or higher. + +## Quick Start + +### Basic Usage + +```python +from netto import calc_netto + +# Calculate net income from 50,000€ gross salary (uses defaults) +net_income = calc_netto(50000) +print(f"Net income: {net_income}€") +# Output: Net income: 30679.18€ +``` + +### Custom Configuration + +```python +from netto import calc_netto, calc_inverse_netto, TaxConfig + +# Configure for 2024, married couple, with children, no church tax +config = TaxConfig( + year=2024, + is_married=True, + has_children=True, + church_tax=0.0, # Set to 0.09 for 9% church tax + extra_health_insurance=0.014 # Additional health insurance rate +) + +# Calculate net income +net = calc_netto(50000, config=config) +print(f"Net income: {net}€") + +# Calculate required gross salary for desired net income +gross = calc_inverse_netto(35000, config=config) +print(f"Required gross: {gross}€") +``` + +### With Deductibles and Verbose Output + +```python +from netto import calc_netto, TaxConfig + +config = TaxConfig(year=2024) + +# Calculate with 2000€ deductibles and verbose output +net = calc_netto( + salary=60000, + deductibles=2000, # e.g., professional expenses + verbose=True, # Print detailed breakdown + config=config +) +``` + +### Advanced: Using Helper Functions + +For more granular control, you can use the intermediate calculation functions: + +```python +from netto import calc_taxable_income, calc_deductible_social_security, get_marginal_tax_rate + +salary = 50000 + +# Calculate deductible social security contributions +deductible_social_security = calc_deductible_social_security(salary) + +# Calculate taxable income +taxable_income = calc_taxable_income( + salary=salary, + deductible_social_security=deductible_social_security +) + +# Get marginal tax rate for this income level +marginal_rate = get_marginal_tax_rate(taxable_income) +print(f"Marginal tax rate: {marginal_rate:.1%}") +``` + +## Configuration + +The `TaxConfig` dataclass provides type-safe configuration: + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `year` | int | 2022 | Tax year (2018-2025 supported) | +| `is_married` | bool | False | Married status (Ehegattensplitting) | +| `has_children` | bool | False | Has children (affects nursing insurance) | +| `church_tax` | float | 0.09 | Church tax rate (0.0-0.09, set to 0.0 for none) | +| `extra_health_insurance` | float | 0.014 | Additional health insurance rate | + +## Supported Tax Years + +| Year | Status | Notes | +|------|--------|-------| +| 2018-2022 | Fully supported | Complete tax data | +| 2023-2025 | Fully supported | Complete tax data | +| 2026-2027 | Planned | To be added | + +## Documentation + +Full documentation is available at [netto.readthedocs.io](https://netto.readthedocs.io/). + +## Data Sources + +All tax calculations are based on official German government sources: + +- **Tax Calculation Formulas**: [BMF Tarifhistorie](https://www.bmf-steuerrechner.de/Tarifhistorie_Steuerrechner.pdf) +- **Wage Tax Calculator**: [BMF Lohnsteuerrechner](https://www.bmf-steuerrechner.de/) +- **Social Security Deductible**: [Vorsorgepauschale](https://www.lohn-info.de/vorsorgepauschale.html) +- **Social Security Rates**: [Sozialversicherungsbeiträge](https://www.lohn-info.de/sozialversicherungsbeitraege2024.html) +- **Solidarity Tax**: [Solidaritätszuschlag](https://www.lohn-info.de/solizuschlag.html) +- **Taxable Income Calculator**: [Reverse Calculator](https://udo-brechtel.de/mathe/est_gsv/reverse_zve_brutto.htm) + +## Development + +### Setup + +```bash +# Clone the repository +git clone https://github.com/0-k/netto.git +cd netto + +# Install in development mode with dev dependencies +pip install -e . +pip install -r requirements-dev.txt + +# Set up pre-commit hooks (recommended) +pre-commit install +``` + +Pre-commit hooks automatically run ruff linting and formatting before each commit, preventing CI failures. + +### Running Tests + +```bash +# First-time setup: install package in editable mode +pip install -e . + +# Run all tests with coverage +python -m pytest --cov=netto test/ + +# Run specific test file +python -m pytest test/test_main.py -v +``` + +**Note**: Use `python -m pytest` (not just `pytest`) to ensure tests run in the correct Python environment where netto is installed. + +### Code Quality + +```bash +# Format code +ruff format . + +# Lint code +ruff check . + +# Fix linting issues automatically +ruff check --fix . +``` + +### Building Documentation + +```bash +cd docs/ +make html +# Open docs/_build/html/index.html in your browser +``` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. ## Credits -Martin Klein, 2025 +Created and maintained by Martin Klein (hi@martinklein.co). + +**Repository**: https://github.com/0-k/netto +**Documentation**: https://netto.readthedocs.io/ +**PyPI**: https://pypi.org/project/netto/ + +--- + +© 2025 Martin Klein diff --git a/examples/examples.py b/examples/examples.py index f9a9294..fd6b8df 100644 --- a/examples/examples.py +++ b/examples/examples.py @@ -30,7 +30,7 @@ is_married=True, has_children=True, church_tax=0.0, # No church tax - extra_health_insurance=0.015 # Slightly higher health insurance + extra_health_insurance=0.015, # Slightly higher health insurance ) # Calculate with custom configuration @@ -44,10 +44,7 @@ # Create a configuration for a single person without church tax in 2024 config_2024_single = TaxConfig( - year=2024, - is_married=False, - has_children=False, - church_tax=0.0 + year=2024, is_married=False, has_children=False, church_tax=0.0 ) # Calculate with different configuration @@ -57,8 +54,14 @@ # Multiple scenarios comparison scenarios = [ ("Single, 2024, no church tax", TaxConfig(year=2024, church_tax=0.0)), - ("Married, 2024, with church tax", TaxConfig(year=2024, is_married=True, church_tax=0.09)), - ("Single with children, 2025", TaxConfig(year=2025, has_children=True, church_tax=0.0)), + ( + "Married, 2024, with church tax", + TaxConfig(year=2024, is_married=True, church_tax=0.09), + ), + ( + "Single with children, 2025", + TaxConfig(year=2025, has_children=True, church_tax=0.0), + ), ] salary = 70000 diff --git a/netto/config.py b/netto/config.py index 16985c8..43dbcfd 100644 --- a/netto/config.py +++ b/netto/config.py @@ -9,26 +9,21 @@ class TaxConfig: Parameters ---------- year : int - Tax year for calculations (default: 2022) + Tax year (2018-2025, default: 2022) has_children : bool - Whether the taxpayer has children (affects nursing insurance, default: False) + Has children (affects nursing insurance) is_married : bool - Whether the taxpayer is married (doubles tax brackets, default: False) + Married status (doubles tax brackets) extra_health_insurance : float - Extra health insurance rate (default: 0.014) + Extra health insurance rate church_tax : float - Church tax rate (default: 0.09) + Church tax rate (set to 0.0 for none) Examples -------- - # Default configuration - config = TaxConfig() - - # Custom configuration for 2025 - config = TaxConfig(year=2025, is_married=True, has_children=True) - - # No church tax - config = TaxConfig(church_tax=0.0) + >>> TaxConfig() + >>> TaxConfig(year=2025, is_married=True, has_children=True) + >>> TaxConfig(church_tax=0.0) """ year: int = 2022 @@ -48,6 +43,8 @@ def __post_init__(self): if not isinstance(self.is_married, bool): raise TypeError(f"is_married must be bool, got {type(self.is_married)}") if self.extra_health_insurance < 0: - raise ValueError(f"extra_health_insurance must be non-negative, got {self.extra_health_insurance}") + raise ValueError( + f"extra_health_insurance must be non-negative, got {self.extra_health_insurance}" + ) if self.church_tax < 0: raise ValueError(f"church_tax must be non-negative, got {self.church_tax}") diff --git a/netto/data_loader.py b/netto/data_loader.py index 32df739..476a855 100644 --- a/netto/data_loader.py +++ b/netto/data_loader.py @@ -14,57 +14,48 @@ import json from pathlib import Path -from typing import Dict, List, Optional from pydantic import BaseModel, Field, field_validator - -# Get the data directory path DATA_DIR = Path(__file__).parent.parent / "data" class TaxBracket(BaseModel): - """Tax bracket configuration for a single bracket.""" - step: float = Field(gt=0, description="Income threshold for this bracket") rate: float = Field(ge=0, le=1, description="Tax rate for this bracket") - const: Optional[List[float]] = Field(default=None, description="Polynomial coefficients") + const: list[float] | None = Field( + default=None, description="Polynomial coefficients" + ) @field_validator("const") @classmethod def validate_const_length(cls, v, info): - """Validate that const array has correct length based on bracket.""" if v is not None and len(v) == 0: raise ValueError("const array cannot be empty") return v class TaxCurve(BaseModel): - """Tax curve configuration for a single year.""" - year: int = Field(ge=2018, le=2030, description="Tax year") - brackets: Dict[str, TaxBracket] = Field(description="Tax brackets (0-3)") + brackets: dict[str, TaxBracket] = Field(description="Tax brackets (0-3)") @field_validator("brackets") @classmethod def validate_brackets(cls, v): - """Ensure we have exactly 4 brackets (0-3).""" if set(v.keys()) != {"0", "1", "2", "3"}: raise ValueError("Tax curve must have exactly 4 brackets (0-3)") return v class SocialSecurityEntry(BaseModel): - """Social security entry for pension, unemployment, health, or nursing.""" - limit: float = Field(gt=0, description="Income limit for this contribution") rate: float = Field(ge=0, le=1, description="Contribution rate") - extra: Optional[float] = Field(default=None, ge=0, le=1, description="Extra rate (nursing only)") + extra: float | None = Field( + default=None, ge=0, le=1, description="Extra rate (nursing only)" + ) class SocialSecurity(BaseModel): - """Social security configuration for a single year.""" - year: int = Field(ge=2018, le=2030, description="Tax year") pension: SocialSecurityEntry unemployment: SocialSecurityEntry @@ -73,22 +64,22 @@ class SocialSecurity(BaseModel): class SoliCurve(BaseModel): - """Solidarity tax configuration for a single year.""" - year: int = Field(ge=2018, le=2030, description="Tax year") - start_taxable_income: float = Field(gt=0, description="Income threshold where soli starts") - start_fraction: float = Field(ge=0, le=1, description="Starting fraction for progressive phase-in") + start_taxable_income: float = Field( + gt=0, description="Income threshold where soli starts" + ) + start_fraction: float = Field( + ge=0, le=1, description="Starting fraction for progressive phase-in" + ) end_rate: float = Field(ge=0, le=1, description="Maximum soli rate") class PensionFactor(BaseModel): - """Pension correction factor for a single year.""" - year: int = Field(ge=2018, le=2030, description="Tax year") factor: float = Field(ge=0, le=1, description="Pension deduction factor") -def load_tax_curve(year: int) -> Dict[int, dict]: +def load_tax_curve(year: int) -> dict[int, dict]: """ Load tax curve for a specific year. @@ -116,7 +107,6 @@ def load_tax_curve(year: int) -> Dict[int, dict]: with open(file_path) as f: data = json.load(f) - # Validate with pydantic tax_curve = TaxCurve(**data) # Convert string keys to integers for backward compatibility @@ -147,13 +137,14 @@ def load_social_security(year: int) -> dict: if not file_path.exists(): if year >= 2026: - raise NotImplementedError(f"Social security data not yet available for {year}") + raise NotImplementedError( + f"Social security data not yet available for {year}" + ) raise FileNotFoundError(f"Social security data not found for year {year}") with open(file_path) as f: data = json.load(f) - # Validate with pydantic social_security = SocialSecurity(**data) return social_security.model_dump(exclude={"year"}) @@ -187,7 +178,6 @@ def load_soli(year: int) -> dict: with open(file_path) as f: data = json.load(f) - # Validate with pydantic soli_curve = SoliCurve(**data) return soli_curve.model_dump(exclude={"year"}) @@ -221,13 +211,12 @@ def load_pension_factor(year: int) -> float: with open(file_path) as f: data = json.load(f) - # Validate with pydantic pension_factor = PensionFactor(**data) return pension_factor.factor -def load_all_tax_curves() -> Dict[int, Dict[int, dict]]: +def load_all_tax_curves() -> dict[int, dict[int, dict]]: """ Load tax curves for all available years. @@ -241,11 +230,11 @@ def load_all_tax_curves() -> Dict[int, Dict[int, dict]]: try: tax_curves[year] = load_tax_curve(year) except FileNotFoundError: - pass # Skip missing years + pass return tax_curves -def load_all_social_security() -> Dict[int, dict]: +def load_all_social_security() -> dict[int, dict]: """ Load social security data for all available years. @@ -259,7 +248,7 @@ def load_all_social_security() -> Dict[int, dict]: try: social_security[year] = load_social_security(year) except (FileNotFoundError, NotImplementedError): - pass # Skip missing years + pass # Add NotImplementedError for 2026+ to maintain backward compatibility social_security[2026] = NotImplementedError @@ -267,7 +256,7 @@ def load_all_social_security() -> Dict[int, dict]: return social_security -def load_all_soli() -> Dict[int, dict]: +def load_all_soli() -> dict[int, dict]: """ Load solidarity tax data for all available years. @@ -281,11 +270,11 @@ def load_all_soli() -> Dict[int, dict]: try: soli_data[year] = load_soli(year) except FileNotFoundError: - pass # Skip missing years + pass return soli_data -def load_all_pension_factors() -> Dict[int, float]: +def load_all_pension_factors() -> dict[int, float]: """ Load pension correction factors for all available years. @@ -299,7 +288,7 @@ def load_all_pension_factors() -> Dict[int, float]: try: pension_factors[year] = load_pension_factor(year) except FileNotFoundError: - pass # Skip missing years + pass return pension_factors diff --git a/netto/main.py b/netto/main.py index be25c43..dd007dd 100644 --- a/netto/main.py +++ b/netto/main.py @@ -10,42 +10,34 @@ def calc_netto( salary: float, deductibles: float = 0, verbose: bool = False, - config: TaxConfig | None = None + config: TaxConfig | None = None, ) -> float: """ - This function calculates the net income for a given year by subtracting the income tax, soli, church tax, and social security amounts from the salary. + Calculate net income from gross salary. Parameters ---------- - salary: float or int - The yearly salary. - deductibles: float or int, optional - Deductibles that reduce the taxable income. Default is 0. + salary: float + Yearly gross salary + deductibles: float, optional + Additional deductibles that reduce taxable income verbose: bool, optional - Determines whether additional information about the calculation should be printed. Default is False. + Print detailed calculation breakdown config : TaxConfig, optional - Tax configuration. If not provided, uses default TaxConfig(). + Tax configuration (uses defaults if not provided) Returns ------- - net_income: float - The net income for a given year. + float + Net income Examples -------- - # Calculate net income for a salary of 50,000 with no additional deductibles - calc_netto(50000) - - # Calculate net income for a salary of 50,000 with additional deductibles of 10,000 - calc_netto(50000, deductibles=10000) - - # Calculate net income for a salary of 50,000 and print additional information - calc_netto(50000, verbose=True) - - # Calculate net income with custom configuration - from netto.config import TaxConfig - config = TaxConfig(year=2025, is_married=True, has_children=True) - calc_netto(50000, config=config) + >>> calc_netto(50000) + >>> calc_netto(50000, deductibles=10000) + >>> calc_netto(50000, verbose=True) + >>> config = TaxConfig(year=2025, is_married=True) + >>> calc_netto(50000, config=config) """ if config is None: config = TaxConfig() @@ -79,46 +71,38 @@ def calc_netto( def calc_inverse_netto( - desired_netto: float, - deductibles: float = 0, - config: TaxConfig | None = None + desired_netto: float, deductibles: float = 0, config: TaxConfig | None = None ) -> float: """ - Calculate gross salary to reach desired net income. - - This function calculates the gross salary needed to reach a desired net income. It uses the `calc_netto()` function to calculate the net income for a given salary, and then uses the `newton()` function to find the salary that produces the desired net income. + Calculate required gross salary to reach desired net income. Parameters ---------- - desired_netto: float or int - The desired net income. - deductibles: float or int, optional - Deductibles that reduce the taxable income. Default is 0. + desired_netto: float + Desired net income + deductibles: float, optional + Additional deductibles that reduce taxable income config : TaxConfig, optional - Tax configuration. If not provided, uses default TaxConfig(). + Tax configuration (uses defaults if not provided) Returns ------- - gross_salary: float - The gross salary needed to reach the desired net income. + float + Required gross salary Examples -------- - # Calculate gross salary needed to reach a net income of 50,000 with no additional deductibles - calc_inverse_netto(50000) - - # Calculate gross salary needed to reach a net income of 50,000 with additional deductibles of 5,000 - calc_inverse_netto(50000, deductibles=5000) - - # Calculate gross salary with custom configuration - from netto.config import TaxConfig - config = TaxConfig(year=2025, is_married=True) - calc_inverse_netto(50000, config=config) + >>> calc_inverse_netto(50000) + >>> calc_inverse_netto(50000, deductibles=5000) + >>> config = TaxConfig(year=2025, is_married=True) + >>> calc_inverse_netto(50000, config=config) """ if config is None: config = TaxConfig() def f(salary): - return calc_netto(salary, deductibles=deductibles, config=config) - desired_netto + return ( + calc_netto(salary, deductibles=deductibles, config=config) - desired_netto + ) return round(newton(f, x0=desired_netto), 0) diff --git a/netto/social_security.py b/netto/social_security.py index f02bb5a..b433ff5 100644 --- a/netto/social_security.py +++ b/netto/social_security.py @@ -10,7 +10,9 @@ def get_rate_pension(salary: float, config: TaxConfig | None = None) -> float: return __get_rate(salary, "pension", config=config) -def __get_rate(salary: float, type: str, extra: float = 0, config: TaxConfig | None = None) -> float: +def __get_rate( + salary: float, type: str, extra: float = 0, config: TaxConfig | None = None +) -> float: if config is None: config = TaxConfig() return ( @@ -46,7 +48,9 @@ def calc_insurance_pension(salary: float, config: TaxConfig | None = None) -> fl return __get_value(salary, "pension", config=config) -def __get_value(salary: float, type: str, extra: float = 0, config: TaxConfig | None = None) -> float: +def __get_value( + salary: float, type: str, extra: float = 0, config: TaxConfig | None = None +) -> float: if config is None: config = TaxConfig() return min( @@ -56,7 +60,9 @@ def __get_value(salary: float, type: str, extra: float = 0, config: TaxConfig | ) -def calc_insurance_unemployment(salary: float, config: TaxConfig | None = None) -> float: +def calc_insurance_unemployment( + salary: float, config: TaxConfig | None = None +) -> float: return __get_value(salary, "unemployment", config=config) @@ -67,7 +73,9 @@ def calc_insurance_health(salary: float, config: TaxConfig | None = None) -> flo return __get_value(salary, "health", extra, config=config) -def calc_insurance_health_deductable(salary: float, config: TaxConfig | None = None) -> float: +def calc_insurance_health_deductable( + salary: float, config: TaxConfig | None = None +) -> float: if config is None: config = TaxConfig() extra = config.extra_health_insurance / 2 @@ -85,12 +93,15 @@ def calc_insurance_nursing(salary: float, config: TaxConfig | None = None) -> fl return __get_value(salary, "nursing", extra, config=config) -def calc_deductible_social_security(salary: float, config: TaxConfig | None = None) -> float: +def calc_deductible_social_security( + salary: float, config: TaxConfig | None = None +) -> float: if config is None: config = TaxConfig() return ( math.ceil( - calc_insurance_pension(salary, config) * correction_factor_pensions[config.year] + calc_insurance_pension(salary, config) + * correction_factor_pensions[config.year] ) + math.ceil(calc_insurance_health_deductable(salary, config)) + math.ceil(calc_insurance_nursing(salary, config)) @@ -107,7 +118,9 @@ def calc_social_security(salary: float, config: TaxConfig | None = None) -> floa ) -def calc_social_security_by_integration(salary: float, config: TaxConfig | None = None) -> float: +def calc_social_security_by_integration( + salary: float, config: TaxConfig | None = None +) -> float: if config is None: config = TaxConfig() pension, _ = quad(lambda s: get_rate_pension(s, config), 0, salary) diff --git a/netto/taxes_income.py b/netto/taxes_income.py index fa1d721..f6923e3 100644 --- a/netto/taxes_income.py +++ b/netto/taxes_income.py @@ -6,7 +6,9 @@ from netto.data_loader import tax_curve as TAX_CURVE_DATA -def get_marginal_tax_rate(taxable_income: float, config: TaxConfig | None = None) -> float: +def get_marginal_tax_rate( + taxable_income: float, config: TaxConfig | None = None +) -> float: """ Calculate the marginal tax rate for a given taxable income. @@ -70,14 +72,11 @@ def get_marginal_tax_rate(taxable_income: float, config: TaxConfig | None = None def __calc_gradient(x_i: float, x_j: float, y_i: float, y_j: float, x: float) -> float: - # Calculate the gradient between the two points (x_i, y_i) and (x_j, y_j) return (1 - (x_j - x) / (x_j - x_i)) * (y_j - y_i) + y_i def calc_taxable_income( - salary: float, - deductible_social_security: float, - deductibles_other: float = 0 + salary: float, deductible_social_security: float, deductibles_other: float = 0 ) -> float: """ Calculate the taxable income for a given salary and deductibles. @@ -161,30 +160,32 @@ def calc_income_tax(taxable_income: float, config: TaxConfig | None = None) -> f ) -def calc_income_tax_by_integration(taxable_income: float, config: TaxConfig | None = None) -> float: +def calc_income_tax_by_integration( + taxable_income: float, config: TaxConfig | None = None +) -> float: """ - Calculate the income tax for a given taxable income by means of integration. - Always available, even when exact integration curve in const.py is not defined. + Calculate income tax by numerical integration of marginal tax rates. Parameters ---------- - taxable_income: float or int - The taxable income for which the income tax should be calculated. + taxable_income: float + Taxable income config : TaxConfig, optional - Tax configuration (uses default if not provided) + Tax configuration (uses defaults if not provided) Returns ------- - income_tax: float - The income tax for the given taxable income. + float + Income tax amount Examples -------- - # Calculate income tax for a taxable income of 10000 - calc_income_tax_by_integration(10000) + >>> calc_income_tax_by_integration(10000) """ if config is None: config = TaxConfig() - income_tax, _ = quad(lambda ti: get_marginal_tax_rate(ti, config), 0, taxable_income) + income_tax, _ = quad( + lambda ti: get_marginal_tax_rate(ti, config), 0, taxable_income + ) return income_tax diff --git a/pyproject.toml b/pyproject.toml index 418b776..f1a8d34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netto" -version = "0.2.0a2" +version = "0.2.0a3" authors = [ { name="Martin Klein", email="hi@martinklein.co" }, ] @@ -30,4 +30,26 @@ classifiers = [ packages = ["netto"] [tool.setuptools.package-data] -netto = ["../data/**/*.json", "../data/**/*.md"] \ No newline at end of file +netto = ["../data/**/*.json", "../data/**/*.md"] + +[tool.ruff] +line-length = 88 # Black-compatible default, widely adopted standard +target-version = "py310" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long (handled by formatter) +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index d3a2502..e0176ab 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,10 +1,9 @@ -black -isort +ruff pytest pytest-cov +pre-commit sphinx myst-parser build -twine sphinx-autoapi sphinx_rtd_theme \ No newline at end of file diff --git a/test/test_config.py b/test/test_config.py index 07ab642..a31234e 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -20,7 +20,7 @@ def test_taxconfig_custom_values(): has_children=True, is_married=True, extra_health_insurance=0.02, - church_tax=0.08 + church_tax=0.08, ) assert config.year == 2025 assert config.has_children is True diff --git a/test/test_data_loader.py b/test/test_data_loader.py index aff242d..6d6fc1b 100644 --- a/test/test_data_loader.py +++ b/test/test_data_loader.py @@ -1,29 +1,27 @@ import pytest -from pathlib import Path from pydantic import ValidationError from netto.data_loader import ( - TaxBracket, - TaxCurve, - SocialSecurityEntry, + PensionFactor, SocialSecurity, + SocialSecurityEntry, SoliCurve, - PensionFactor, - load_tax_curve, - load_social_security, - load_soli, - load_pension_factor, - load_all_tax_curves, + TaxBracket, + TaxCurve, + correction_factor_pensions, + load_all_pension_factors, load_all_social_security, load_all_soli, - load_all_pension_factors, - tax_curve, + load_all_tax_curves, + load_pension_factor, + load_social_security, + load_soli, + load_tax_curve, social_security_curve, soli_curve, - correction_factor_pensions, + tax_curve, ) - # Tests for Pydantic Models @@ -74,7 +72,7 @@ def test_tax_curve_valid(): "1": TaxBracket(step=14927, rate=0.14, const=[995.21, 1400]), "2": TaxBracket(step=58597, rate=0.2397, const=[208.85, 2397, 950.96]), "3": TaxBracket(step=277826, rate=0.42, const=[0.42, 9336.45]), - } + }, ) assert curve.year == 2022 assert len(curve.brackets) == 4 @@ -88,7 +86,7 @@ def test_tax_curve_invalid_brackets(): brackets={ "0": TaxBracket(step=10000, rate=0.0), "1": TaxBracket(step=20000, rate=0.14), - } + }, ) @@ -102,7 +100,7 @@ def test_tax_curve_invalid_year(): "1": TaxBracket(step=20000, rate=0.14), "2": TaxBracket(step=30000, rate=0.24), "3": TaxBracket(step=40000, rate=0.42), - } + }, ) @@ -137,7 +135,7 @@ def test_social_security_valid(): pension=SocialSecurityEntry(limit=84600, rate=0.093), unemployment=SocialSecurityEntry(limit=84600, rate=0.012), health=SocialSecurityEntry(limit=58050, rate=0.073, extra=0.007), - nursing=SocialSecurityEntry(limit=58050, rate=0.01525, extra=0.0035) + nursing=SocialSecurityEntry(limit=58050, rate=0.01525, extra=0.0035), ) assert ss.year == 2022 assert ss.pension.limit == 84600 @@ -147,10 +145,7 @@ def test_social_security_valid(): def test_soli_curve_valid(): """Test creating a valid SoliCurve""" soli = SoliCurve( - year=2022, - start_taxable_income=16956, - start_fraction=0.119, - end_rate=0.055 + year=2022, start_taxable_income=16956, start_fraction=0.119, end_rate=0.055 ) assert soli.year == 2022 assert soli.start_taxable_income == 16956 @@ -266,7 +261,7 @@ def test_load_all_social_security(): assert len(ss_data) >= 8 # At least 2018-2025 # Check for 2026 NotImplementedError marker assert 2026 in ss_data - assert ss_data[2026] == NotImplementedError + assert ss_data[2026] is NotImplementedError def test_load_all_soli(): diff --git a/test/test_main.py b/test/test_main.py index 1e32e9d..d7853ce 100644 --- a/test/test_main.py +++ b/test/test_main.py @@ -1,62 +1,64 @@ -import pytest from io import StringIO from unittest.mock import patch -from netto.config import TaxConfig +import pytest + import netto.main as main +from netto.config import TaxConfig @pytest.fixture def default_config(): """Fixture providing default config for tests""" - return TaxConfig( - extra_health_insurance=0.014, - church_tax=0.09, - has_children=False - ) + return TaxConfig(extra_health_insurance=0.014, church_tax=0.09, has_children=False) @pytest.fixture def alternate_config(): """Fixture providing alternate config for tests""" - return TaxConfig( - extra_health_insurance=0.015, - church_tax=0.0, - has_children=True - ) - - -@pytest.mark.parametrize("salary,expected", [ - (0, 0), - (30000, 20554.38), - (60000, 35796.68), - (90000, 49956.92), - (120000, 64965.08), -]) + return TaxConfig(extra_health_insurance=0.015, church_tax=0.0, has_children=True) + + +@pytest.mark.parametrize( + "salary,expected", + [ + (0, 0), + (30000, 20554.38), + (60000, 35796.68), + (90000, 49956.92), + (120000, 64965.08), + ], +) def test_calc_netto_with_default_config(salary, expected, default_config): """Test calc_netto with various salaries using default config""" result = main.calc_netto(salary, config=default_config) assert abs(result - expected) < 1 -@pytest.mark.parametrize("salary,expected", [ - (30000, 20894.58), - (60000, 36909.71), - (90000, 52091.39), - (120000, 68238.23), -]) +@pytest.mark.parametrize( + "salary,expected", + [ + (30000, 20894.58), + (60000, 36909.71), + (90000, 52091.39), + (120000, 68238.23), + ], +) def test_calc_netto_with_alternate_config(salary, expected, alternate_config): """Test calc_netto with alternate config (no church tax, with children)""" result = main.calc_netto(salary, config=alternate_config) assert abs(result - expected) < 1 -@pytest.mark.parametrize("desired_netto,expected_gross", [ - (20894.58, 30000), - (36909.71, 60000), - (52091.39, 90000), - (68238.23, 120000), -]) +@pytest.mark.parametrize( + "desired_netto,expected_gross", + [ + (20894.58, 30000), + (36909.71, 60000), + (52091.39, 90000), + (68238.23, 120000), + ], +) def test_calc_inverse_netto(desired_netto, expected_gross, alternate_config): """Test inverse netto calculation""" result = main.calc_inverse_netto(desired_netto, config=alternate_config) @@ -99,7 +101,7 @@ def test_calc_inverse_netto_with_default_none_config(): # Use a value that we know works well with Newton's method result = main.calc_inverse_netto(30000) # Should use default TaxConfig - assert isinstance(result, (int, float)) + assert isinstance(result, int | float) assert result > 0 # Verify the result makes sense (gross should be higher than net) assert result > 30000 diff --git a/test/test_social_security.py b/test/test_social_security.py index d52eaee..1dd49c3 100644 --- a/test/test_social_security.py +++ b/test/test_social_security.py @@ -1,65 +1,73 @@ import pytest -from netto.config import TaxConfig import netto.social_security as social_security +from netto.config import TaxConfig @pytest.fixture def default_config(): """Fixture providing default config for tests""" - return TaxConfig( - extra_health_insurance=0.014, - church_tax=0.09, - has_children=False - ) - - -@pytest.mark.parametrize("salary,expected", [ - (0, 0), - (10000, 0.093), - (84600, 0.093), - (84601, 0), - (100000, 0), -]) + return TaxConfig(extra_health_insurance=0.014, church_tax=0.09, has_children=False) + + +@pytest.mark.parametrize( + "salary,expected", + [ + (0, 0), + (10000, 0.093), + (84600, 0.093), + (84601, 0), + (100000, 0), + ], +) def test_get_rate_pension(salary, expected, default_config): """Test pension rate calculation""" result = social_security.get_rate_pension(salary, default_config) assert result == expected -@pytest.mark.parametrize("salary,expected", [ - (0, 0), - (10000, 0.08), - (58050, 0.08), - (58051, 0), - (100000, 0), -]) +@pytest.mark.parametrize( + "salary,expected", + [ + (0, 0), + (10000, 0.08), + (58050, 0.08), + (58051, 0), + (100000, 0), + ], +) def test_get_rate_health(salary, expected, default_config): """Test health insurance rate calculation""" result = social_security.get_rate_health(salary, default_config) assert result == expected -@pytest.mark.parametrize("salary,expected", [ - (0, 0), - (10000, 0.093 * 10000), - (84600, 0.093 * 84600), - (84601, 0.093 * 84600), - (100000, 0.093 * 84600), -]) +@pytest.mark.parametrize( + "salary,expected", + [ + (0, 0), + (10000, 0.093 * 10000), + (84600, 0.093 * 84600), + (84601, 0.093 * 84600), + (100000, 0.093 * 84600), + ], +) def test_calc_insurance_pension(salary, expected, default_config): """Test pension insurance calculation""" result = social_security.calc_insurance_pension(salary, default_config) assert result == expected -@pytest.mark.parametrize("salary,expected", [ - (0, 0), - (10000, 0.08 * 10000), - (58050, 0.08 * 58050), - (58051, 0.08 * 58050), - (100000, 0.08 * 58050), -]) +@pytest.mark.parametrize( + "salary,expected", + [ + (0, 0), + (10000, 0.08 * 10000), + (58050, 0.08 * 58050), + (58051, 0.08 * 58050), + (100000, 0.08 * 58050), + ], +) def test_calc_insurance_health(salary, expected, default_config): """Test health insurance calculation""" result = social_security.calc_insurance_health(salary, default_config) @@ -70,40 +78,47 @@ def test_calc_deductible_social_security(default_config): """Test deductible social security calculation""" assert social_security.calc_deductible_social_security(0, default_config) == 0 # https://www.lohn-info.de/vorsorgepauschale.html - assert social_security.calc_deductible_social_security(30000, default_config) == 2456 + 2310 + 563 + assert ( + social_security.calc_deductible_social_security(30000, default_config) + == 2456 + 2310 + 563 + ) -@pytest.mark.parametrize("salary", [ - 0, 10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000, 100000 -]) +@pytest.mark.parametrize( + "salary", [0, 10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000, 100000] +) def test_sameness_of_calc_social_security(salary, default_config): """Test that both social security calculation methods give same results""" result_direct = social_security.calc_social_security(salary, default_config) - result_integration = social_security.calc_social_security_by_integration(salary, default_config) + result_integration = social_security.calc_social_security_by_integration( + salary, default_config + ) assert result_direct == result_integration -@pytest.mark.parametrize("salary", [ - 0, 10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000, 100000 -]) +@pytest.mark.parametrize( + "salary", [0, 10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000, 100000] +) def test_sameness_of_calc_social_security_different_config(salary): """Test social security calculation with different config""" - config = TaxConfig( - extra_health_insurance=0.015, - has_children=True - ) + config = TaxConfig(extra_health_insurance=0.015, has_children=True) result_direct = social_security.calc_social_security(salary, config) - result_integration = social_security.calc_social_security_by_integration(salary, config) + result_integration = social_security.calc_social_security_by_integration( + salary, config + ) assert result_direct == result_integration -@pytest.mark.parametrize("salary,expected", [ - (0, 0), - (10000, 0.0805), - (58050, 0.0805), - (58051, 0), - (100000, 0), -]) +@pytest.mark.parametrize( + "salary,expected", + [ + (0, 0), + (10000, 0.0805), + (58050, 0.0805), + (58051, 0), + (100000, 0), + ], +) def test_get_rate_health_different_config(salary, expected): """Test health rate with different extra health insurance""" config = TaxConfig(extra_health_insurance=0.015) @@ -111,13 +126,16 @@ def test_get_rate_health_different_config(salary, expected): assert abs(result - expected) < 0.0001 -@pytest.mark.parametrize("salary,expected", [ - (0, 0), - (10000, 0.01875), - (58050, 0.01875), - (58051, 0), - (100000, 0), -]) +@pytest.mark.parametrize( + "salary,expected", + [ + (0, 0), + (10000, 0.01875), + (58050, 0.01875), + (58051, 0), + (100000, 0), + ], +) def test_get_rate_nursing_no_children(salary, expected): """Test nursing rate without children (includes extra rate)""" config = TaxConfig(has_children=False) @@ -125,13 +143,16 @@ def test_get_rate_nursing_no_children(salary, expected): assert result == expected -@pytest.mark.parametrize("salary,expected", [ - (0, 0), - (10000, 0.01525), - (58050, 0.01525), - (58051, 0), - (100000, 0), -]) +@pytest.mark.parametrize( + "salary,expected", + [ + (0, 0), + (10000, 0.01525), + (58050, 0.01525), + (58051, 0), + (100000, 0), + ], +) def test_get_rate_nursing_with_children(salary, expected): """Test nursing rate with children (no extra rate)""" config = TaxConfig(has_children=True) @@ -191,5 +212,5 @@ def test_calc_social_security_with_default_none_config(): def test_calc_deductible_social_security_with_default_none_config(): """Test that calc_deductible_social_security works when config=None""" result = social_security.calc_deductible_social_security(50000) - assert isinstance(result, (int, float)) + assert isinstance(result, int | float) assert result >= 0 diff --git a/test/test_taxes_income.py b/test/test_taxes_income.py index 638298b..cff960a 100644 --- a/test/test_taxes_income.py +++ b/test/test_taxes_income.py @@ -1,43 +1,45 @@ import pytest -from netto.config import TaxConfig import netto.taxes_income as taxes_income +from netto.config import TaxConfig @pytest.fixture def default_config(): """Fixture providing default config for tests""" - return TaxConfig( - extra_health_insurance=0.014, - church_tax=0.09, - has_children=False - ) + return TaxConfig(extra_health_insurance=0.014, church_tax=0.09, has_children=False) -@pytest.mark.parametrize("taxable_income,expected_rate", [ - (-1000, 0), - (0, 0), - (10346, 0), - (10347, 0.14), - (14926, 0.2397), - (58596, 0.42), - (58597, 0.42), - (100000, 0.42), - (277826, 0.45), - (277827, 0.45), -]) +@pytest.mark.parametrize( + "taxable_income,expected_rate", + [ + (-1000, 0), + (0, 0), + (10346, 0), + (10347, 0.14), + (14926, 0.2397), + (58596, 0.42), + (58597, 0.42), + (100000, 0.42), + (277826, 0.45), + (277827, 0.45), + ], +) def test_get_marginal_tax_rate(taxable_income, expected_rate, default_config): """Test marginal tax rate calculation for various income levels""" result = taxes_income.get_marginal_tax_rate(taxable_income, default_config) assert result == expected_rate -@pytest.mark.parametrize("taxable_income,expected_rate", [ - (10346, 0), - (10347, 0), - (10346 * 2, 0), - (10347 * 2, 0.14), -]) +@pytest.mark.parametrize( + "taxable_income,expected_rate", + [ + (10346, 0), + (10347, 0), + (10346 * 2, 0), + (10347 * 2, 0.14), + ], +) def test_get_marginal_tax_rate_married(taxable_income, expected_rate): """Test marginal tax rate for married couples (doubled brackets)""" config = TaxConfig(is_married=True) @@ -45,15 +47,30 @@ def test_get_marginal_tax_rate_married(taxable_income, expected_rate): assert result == expected_rate -@pytest.mark.parametrize("taxable_income", [ - 12000, - 0, 10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000, 100000, - 300000, -]) +@pytest.mark.parametrize( + "taxable_income", + [ + 12000, + 0, + 10000, + 20000, + 30000, + 40000, + 50000, + 60000, + 70000, + 80000, + 90000, + 100000, + 300000, + ], +) def test_sameness_of_calc_income_tax_methods(taxable_income, default_config): """Test that both income tax calculation methods give similar results""" result_direct = taxes_income.calc_income_tax(taxable_income, default_config) - result_integration = taxes_income.calc_income_tax_by_integration(taxable_income, default_config) + result_integration = taxes_income.calc_income_tax_by_integration( + taxable_income, default_config + ) assert abs(result_direct - result_integration) < 0.1 diff --git a/test/test_taxes_other.py b/test/test_taxes_other.py index db177ab..d34d51f 100644 --- a/test/test_taxes_other.py +++ b/test/test_taxes_other.py @@ -1,47 +1,52 @@ import pytest -from netto.config import TaxConfig import netto.taxes_other as taxes_other +from netto.config import TaxConfig @pytest.fixture def default_config(): """Fixture providing default config for tests""" - return TaxConfig( - extra_health_insurance=0.014, - church_tax=0.09, - has_children=False - ) - - -@pytest.mark.parametrize("income_tax,expected", [ - (-1000, 0), - (0, 0), - (16956, 0), - (100000, 5500), -]) + return TaxConfig(extra_health_insurance=0.014, church_tax=0.09, has_children=False) + + +@pytest.mark.parametrize( + "income_tax,expected", + [ + (-1000, 0), + (0, 0), + (16956, 0), + (100000, 5500), + ], +) def test_calc_soli_exact(income_tax, expected, default_config): """Test solidarity tax calculation with exact values""" result = taxes_other.calc_soli(income_tax, default_config) assert result == expected -@pytest.mark.parametrize("income_tax,expected", [ - (16957, 0.119), - (17514.96, 66.48), - (26913.96, 1185.0), -]) +@pytest.mark.parametrize( + "income_tax,expected", + [ + (16957, 0.119), + (17514.96, 66.48), + (26913.96, 1185.0), + ], +) def test_calc_soli_approximate(income_tax, expected, default_config): """Test solidarity tax calculation with approximate values""" result = taxes_other.calc_soli(income_tax, default_config) assert abs(result - expected) < 0.1 -@pytest.mark.parametrize("income_tax,expected", [ - (-1000, 0), - (0, 0), - (10000, 900), -]) +@pytest.mark.parametrize( + "income_tax,expected", + [ + (-1000, 0), + (0, 0), + (10000, 900), + ], +) def test_calc_church_tax(income_tax, expected, default_config): """Test church tax calculation""" result = taxes_other.calc_church_tax(income_tax, default_config)