Skip to content

Feature/first #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Apr 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/Linting.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Linting

on: [pull_request]

jobs:
Linting:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11"]
fail-fast: false
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install pkg-config libxml2-dev libxmlsec1-dev libxmlsec1-openssl
python -m pip install --upgrade pip
pip install uv
uv pip install --system .
uv pip install --system flake8 pylint

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change


- name: Analysing the code with pylint
run: |
pylint --rcfile=.pylintrc $(git ls-files '*.py')

- name: Analysing the code with flake8
run: |
flake8 --extend-ignore=E501,E251 $(git ls-files '*.py')
37 changes: 37 additions & 0 deletions .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# This workflow will upload a Python Package using Twine when a release is created
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.

name: Upload Python Package

on:
release:
types: [published]

permissions:
contents: read

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v3
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install build
- name: Build package
run: python -m build
- name: Publish package
uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.vscode/

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
Expand Down
6 changes: 6 additions & 0 deletions .pylintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[pylint.messages_control]
disable =
C0301, # Line too long
I1101, E1101, # C-modules members
R0913, # Too many arguments
R0914 # Too many local variables
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.11
107 changes: 106 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,106 @@
# python-skat-webservice
# python-skat-webservice

This Python library is used to call the Danish SKAT eIndkomst SOAP webservice.

<https://info.skat.dk/data.aspx?oid=4247>

Currently the following services are supported:

- IndkomstOplysningPersonHent

## Prerequisites

### Service agreement

Before using the webservices you need to setup a service agreement with SKAT.

Read more here: <https://info.skat.dk/data.aspx?oid=2248828>

In the service agreement you will receive 3 codes that is used when calling the services:

- AbonnentTypeKode
- AbonnementTypeKode
- AdgangFormaalTypeKode

All three codes are numbers 3-5 digits long.

### Certificates

The OCES3 P12 certificate you use to register with SKAT needs to be converted to two PEM
certificates before being used in the code. You can use openssl for this:

```bash
openssl pkcs12 -in Certificate.p12 -out Certificate.crt.pem -clcerts -nokeys
openssl pkcs12 -in Certificate.p12 -out Certificate.key.pem -nocerts -nodes
```

## IndkomstOplysningPersonHent

### Usage

The IndkomstOplysningPersonHent allows you to get income information about a single person for a given
range of months.

```python
from python_skat_webservice.soap_signer import SOAPSigner
from python_skat_webservice.common import CallerInfo
from python_skat_webservice.indkomst_oplysning_person_hent import search_income

if __name__ == '__main__':
signer = SOAPSigner("something/certifcate.crt.pem", "something/certificate.key.pem")

caller = CallerInfo(
se_number="12345678", # Your company's SE number
abonnent_type_kode="123",
abonnement_type_kode="4567",
adgang_formaal_type_kode="456",
caller_id="My caller id" # Any id to identify you with SKAT. Can be anything.
)

result = search_income(
cpr="1234567890",
month_from="202401", # yyyymm
month_to="202401", # yyyymm
transaction_id="My transaction id", # Any id to identify the transaction. Can be anything.
caller_info=caller,
soap_signer=signer
)

print(result)
```

### Output

The output is a SOAP xml envelope with the search results in the body.
The body is structured something like this:

```text
Person
├── Company
│ ├── Period
│ │ ├── Form
│ │ │ ├── Form ID
│ │ │ ├── Field
│ │ │ │ ├── Field ID
│ │ │ │ ├── Type
│ │ │ │ └── Value
│ │ │ └── Field
│ │ │ └── ...
│ │ ├── Form
│ │ │ └── ...
│ │ └── ...
│ └── Period
│ └── ...
└── Company
└── ...
```

The income information is first grouped by the paying company.
Then by period (usually by month).
Then in forms (blanket) with fields.

Forms can be layered so a form may contain multiple subforms.

Descriptions of the forms and fields can be found in 'underbilag 1' here: <https://info.skat.dk/data.aspx?oid=2248828&chk=220344>

Example: The form 16001 field 100000000000000057 is "A-indkomst, hvoraf der betales AM-bidrag".
22 changes: 22 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[project]
name = "python-skat-webservice"
version = "0.1.0"
description = "This project makes it easier to use the SKAT webservices."
readme = "README.md"
requires-python = ">=3.11"

dependencies = [
"lxml>=5.3.2",
"requests>=2.32.3",
"xmlsec>=1.3.15",
]

[build-system]
requires = ["setuptools>=65.0"]
build-backend = "setuptools.build_meta"

[tool.uv]
dev-dependencies = [
"flake8>=7.2.0",
"pylint>=3.3.6",
]
Empty file.
19 changes: 19 additions & 0 deletions src/python_skat_webservice/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""This module contains common classes and functions."""

from dataclasses import dataclass


@dataclass
class CallerInfo:
"""A dataclass that describes the caller of a webservice.
caller_id: The id of the caller used to trace usage of the service. Can be anything.
se_number: The SE-number of the caller.
abonnent_type_kode: The AbonnentTypeKode from the service agreement.
abonnement_type_kode: The AbonnementTypeKode from the service agreement.
adgang_formaal_type_kode: The AdgangFormaalTypeKode from the service agreement.
"""
caller_id: str
se_number: str
abonnent_type_kode: str
abonnement_type_kode: str
adgang_formaal_type_kode: str
121 changes: 121 additions & 0 deletions src/python_skat_webservice/indkomst_oplysning_person_hent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""This module interacts with the IndkomstOplysningPersonHent webservice."""

from lxml import etree
import requests

from python_skat_webservice.soap_signer import SOAPSigner
from python_skat_webservice.common import CallerInfo

NSMAP = {
"soap-env": "http://schemas.xmlsoap.org/soap/envelope/",
"ns0": "http://rep.oio.dk/skat.dk/eindkomst/",
"ns1": "http://rep.oio.dk/skat.dk/basis/kontekst/xml/schemas/2006/09/01/",
"ns2": "http://rep.oio.dk/skat.dk/eindkomst/class/abonnenttype/xml/schemas/20071202/",
"ns3": "http://rep.oio.dk/skat.dk/eindkomst/class/abonnementtype/xml/schemas/20071202/",
"ns4": "http://rep.oio.dk/skat.dk/eindkomst/class/adgangformaaltype/xml/schemas/20071202/",
"ns5": "http://rep.oio.dk/skat.dk/motor/class/virksomhed/xml/schemas/20080401/",
"ns6": "http://rep.oio.dk/skat.dk/eindkomst/class/indkomstoplysningadgangmedarbejderidentifikator/xml/schemas/20071202/",
"ns7": "http://rep.oio.dk/cpr.dk/xml/schemas/core/2005/03/18/",
"ns8": "http://rep.oio.dk/skat.dk/eindkomst/class/soegeaarmaanedfrakode/xml/schemas/20071202/",
"ns9": "http://rep.oio.dk/skat.dk/eindkomst/class/soegeaarmaanedtilkode/xml/schemas/20071202/",
"ns10": "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd",
}


def create_envelope(*, cpr: str, month_from: str, month_to: str, transaction_id: str, caller_info: CallerInfo, soap_signer: SOAPSigner) -> str:
"""Create a SOAP envelope for calling the service.

Args:
cpr: The cpr-number to search on.
month_from: The beginning of the search interval. Formatted as "yyyymm"
month_to: The end of the search interval. Formatted as "yyyymm"
transaction_id: An id to identify the transaction. Can be anything.
caller_info: A CallerInfo object that describes the caller.
soap_signer: The SOAPSigner object used to sign the call.

Returns:
The signed SOAP envelope as a string.
"""
# Create envelope
envelope = etree.Element(f"{{{NSMAP['soap-env']}}}Envelope", nsmap=NSMAP)

# Create SOAP Body
body = etree.SubElement(envelope, f"{{{NSMAP['soap-env']}}}Body")

# Main request structure
request = etree.SubElement(body, f"{{{NSMAP['ns0']}}}IndkomstOplysningPersonHent_I")

# HovedOplysninger
hoved = etree.SubElement(request, f"{{{NSMAP['ns1']}}}HovedOplysninger")
tid = etree.SubElement(hoved, f"{{{NSMAP['ns1']}}}TransaktionIdentifikator")
tid.text = transaction_id

# IndkomstOplysningPersonInddata
inddata = etree.SubElement(request, f"{{{NSMAP['ns0']}}}IndkomstOplysningPersonInddata")

# AbonnentAdgangStruktur
adgang = etree.SubElement(inddata, f"{{{NSMAP['ns0']}}}AbonnentAdgangStruktur")
etree.SubElement(adgang, f"{{{NSMAP['ns2']}}}AbonnentTypeKode").text = caller_info.abonnent_type_kode
etree.SubElement(adgang, f"{{{NSMAP['ns3']}}}AbonnementTypeKode").text = caller_info.abonnement_type_kode
etree.SubElement(adgang, f"{{{NSMAP['ns4']}}}AdgangFormaalTypeKode").text = caller_info.adgang_formaal_type_kode

# AbonnentStruktur
abonnent = etree.SubElement(inddata, f"{{{NSMAP['ns0']}}}AbonnentStruktur")
virk_struct = etree.SubElement(abonnent, f"{{{NSMAP['ns0']}}}AbonnentVirksomhedStruktur")
virk = etree.SubElement(virk_struct, f"{{{NSMAP['ns0']}}}AbonnentVirksomhed")
etree.SubElement(virk, f"{{{NSMAP['ns5']}}}VirksomhedSENummerIdentifikator").text = caller_info.se_number
etree.SubElement(abonnent, f"{{{NSMAP['ns6']}}}IndkomstOplysningAdgangMedarbejderIdentifikator").text = caller_info.caller_id

# IndkomstOplysningValg
valg = etree.SubElement(inddata, f"{{{NSMAP['ns0']}}}IndkomstOplysningValg")
samling = etree.SubElement(valg, f"{{{NSMAP['ns0']}}}IndkomstPersonSamling")
soege = etree.SubElement(samling, f"{{{NSMAP['ns0']}}}PersonIndkomstSoegeStruktur")
etree.SubElement(soege, f"{{{NSMAP['ns7']}}}PersonCivilRegistrationIdentifier").text = cpr
lukket = etree.SubElement(soege, f"{{{NSMAP['ns0']}}}SoegeAarMaanedLukketStruktur")
etree.SubElement(lukket, f"{{{NSMAP['ns8']}}}SoegeAarMaanedFraKode").text = month_from
etree.SubElement(lukket, f"{{{NSMAP['ns9']}}}SoegeAarMaanedTilKode").text = month_to

# Sign envelope and create xml string
soap_signer.sign_soap_envelope(envelope)
return etree.tostring(envelope, pretty_print=False, xml_declaration=True, encoding="utf-8").decode()


def search_income(*, cpr: str, month_from: str, month_to: str, transaction_id: str, caller_info: CallerInfo, soap_signer: SOAPSigner, timeout: int = 30) -> str:
"""Search the income information on the given cpr-number for the given month interval.

Args:
cpr: The cpr-number to search on.
month_from: The beginning of the search interval. Formatted as "yyyymm"
month_to: The end of the search interval. Formatted as "yyyymm"
transaction_id: An id to identify the transaction. Can be anything.
caller_info: A CallerInfo object that describes the caller.
soap_signer: The SOAPSigner object used to sign the call.
timeout: The time in seconds to wait for the http call.

Raises:
HTTPError: If the server didn't return a 200 status code.

Returns:
The raw xml response from the server.
"""
msg = create_envelope(
cpr=cpr,
month_from=month_from,
month_to=month_to,
transaction_id=transaction_id,
caller_info=caller_info,
soap_signer=soap_signer
)

# Call service
url = "https://services.extranet.skat.dk/vericert/services/IndkomstOplysningPersonHentV2ServicePort"

headers = {
'content-type': 'text/xml',
'SOAPAction': 'IndkomstOplysningPersonHent'
}

response = requests.post(url=url, data=msg, headers=headers, timeout=timeout)
response.raise_for_status()

return response.text
Loading