theme rss
visitor@fireatwill:~/projects$ ↑ history · enter to run
~/projects/0x0105
fireatwill ¬ 0x0105 ¬ projects2026.07

PBS

author Sofus Bech status working stack python · cli · sftp repo github.com/sofus-bech/pbs-tool license MIT

A command-line tool for the unglamorous end of Danish banking: generate a correctly formatted PBS (Pengeinstitutternes BetalingsSystemer) enrollment file from customer data, verify every line is exactly 128 characters, and ship it to the bank over SFTP with an SSH key — with a real Danish CPR validator, modulus-11 and all, built in.

pythonclisftpcpr

Some formats predate the idea that a computer would ever read them back to you kindly. Enrolling customers in Danish Betalingsservice means producing a fixed-width file where every single line is exactly 128 characters — right fields, right padding — or the bank quietly rejects the whole batch. Doing that by hand in a text editor is how you lose an afternoon hunting one misplaced space.

The problem

Two things go wrong, and both fail silently downstream. A line that's 127 characters looks fine to a human and is garbage to the bank. And the CPR number you typed — the Danish personal ID at the heart of each record — has real structure most tools never check, so a transposed digit sails straight through to a rejected submission days later.

The format is unforgiving, so the tool has to be — before the file ever leaves your machine.

How it works

It's a small menu-driven CLI — one operator, one file at a time, no surprises:

runme.py
=== Main Menu - PBS Enrollment ===
1. Create enrollment file
2. Verify the file
3. Send file to PBS
4. Reset and archive
5. Setup SFTP connection
6. Exit

The most interesting part is the CPR validator. It runs the full spec — date check, the century table, and a modulus-11 checksum — but it's careful about a real-world wrinkle: mod-11 was abandoned for numbers issued after 2007, so a structurally valid number that fails the checksum isn't wrong, it's just unverifiable. So it reports three states, not two:

cpr_validator.py
# validate a CPR against the full Danish spec, standalone
python3 cpr_validator.py 010190-XXXX
# → VALID         passes every check, modulus-11 included
# → UNVERIFIABLE  well-formed but fails mod-11 (likely a post-2007 number)
# → INVALID       definitively wrong: bad date or century/sequence combo

Generation builds the record, verify asserts every line hits 128 characters, and send uploads over SFTP with an SSH key via paramiko. Old batches get archived and the working directory swept clean, so the next run starts from zero.

What I'd do differently

Roadmap

It's a deliberately small tool that does its one job. If it grows, it's a batch validation report over a folder of files and moving credentials into the keychain — not features, just fewer sharp edges.


Thanks for reading. Found a bug or have a sharper idea? get in touch — or run cat 0x0104 above for another small CLI.