ESR Einzahlungsschein QR CH

posted in: Allgemein | 0

Erstellen von Einzahlungsscheinen mit einem QR Code CH welches eine bestehende Rechnung PDF ausliest und aus diesen Informationen einen Einzahlungsschein inkl QR Code erstellt – Excel → QR (Node.js, Poppler, Watch-OperaQR, Aufgabenplanung, ClawPDF)

0) VORAUSSETZUNGEN

  • Windows 11 / Windows Server 2022
  • Netzwerkzugriff & Schreibrechte auf:
  • \192.168.01.20\data\Rechnungen ohne Einzahlungsschein
  • \192.168.01.20\data\Vorauszahlungen
  • (Empfohlen) Eigenes Dienst-/Aufgaben-Konto für die geplante Aufgabe mit minimal nötigen Rechten

1) DOWNLOADS (OFFIZIELLE QUELLEN)

Node.js (Windows x64 Installer / .msi)
https://nodejs.org/en/download

Poppler für Windows (ZIP-Pakete, Releases)
https://github.com/oschwartz10612/poppler-windows/releases/

ClawPDF 0.8.4 (Releases – Setup-EXE als Asset)
https://github.com/clawsoftware/clawPDF/releases/tag/0.8.4

Hinweise:

  • Für Poppler die Version „24.08.0“ als ZIP laden (z. B. poppler-24.08.0-…-win64.zip).
  • Für ClawPDF die Setup-EXE der Version 0.8.4 verwenden.

2) NODE.JS INSTALLIEREN & PRÜFEN

  1. node-v22.19.0-x64.msi (oder aktuelle x64 .msi) installieren (Standard-Optionen).
  2. Neue PowerShell öffnen und prüfen:
    npm -v
    node -v

3) POPPLER 24.08.0 ENTPACKEN & PRÜFEN

  1. Poppler-ZIP nach
    C:\Program Files (x86)\poppler-24.08.0
    entpacken (Struktur: …\poppler-24.08.0\Library\bin\pdftoppm.exe).
  2. In derselben PowerShell:
    cd ‚C:\Program Files (x86)\poppler-24.08.0\Library\bin‘
    .\pdftoppm.exe -v
    setx PATH «$env:Path;C:\Program Files (x86)\poppler-24.08.0\Library\bin»

Tipp: setx wirkt erst in neu geöffneten Fenstern. Das Watch-OperaQR-Skript ergänzt PATH zusätzlich zur Laufzeit.

4) QR-PROJEKT ABLEGEN (DOKUMENTE) & RECHTE

  1. ZIP (z. B. OperaQR_Auto_Kit_v16_CH28.zip) nach
    C:\Users\\Documents\
    kopieren und entpacken nach:
    C:\Users\\Documents\qr-scan-append
  2. PowerShell-Berechtigungen:
    Set-ExecutionPolicy -Scope CurrentUser RemoteSigned -Force
    Unblock-File «$env:USERPROFILE\Documents\qr-scan-append*.ps1»
    Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
  3. Abhängigkeiten installieren:

cd ‚C:\Users\username\Documents\qr-scan-append‘

cd «$env:USERPROFILE\Documents\qr-scan-append»
npm install

  1. cd «$env:USERPROFILE\Documents\qr-scan-append»
  2. Get-Item .\package.json
  3. npm install
  4. Minimal sicherstellen, dass die Kernpakete da sind npm i pdf-lib swissqrbill pdfjs-dist @pdf-lib/fontkit yargs
  5. npm i pdf-lib swissqrbill pdfjs-dist @pdf-lib/fontkit yargs
  6. # Pfad zu deiner Poppler-Installation (bitte anpassen, falls anders)
    $pop = ‚C:\Program Files (x86)\poppler-24.08.0\Library\bin‘

if (Test-Path «$pop\pdftoppm.exe») {
$env:PATH = «$pop;$env:PATH»
Write-Host «Poppler in PATH aufgenommen: $pop» -ForegroundColor Green
pdftoppm -v # sollte jetzt die Version zeigen
} else {
Write-Host «pdftoppm.exe nicht gefunden unter: $pop» -ForegroundColor Red
}

Danach den Job erneut starten:

C:\Users\username\Documents\qr-scan-append\Watch-OperaQR.ps1

  1. Optional zusätzlich dauerhaft per Umgebungsvariable (neues Fenster) ‚Path‘,
    $env:Path + ‚;C:\Program Files (x86)\poppler-24.08.0\Library\bin‘,
    ‚User‘
    )

5) WATCH-OPERAQR EINSTELLEN (IBAN-Umschaltung „Vorauszahlung“)

  • Watch-OperaQR.ps1 beobachtet standardmäßig:
  • \192.168.01.20\data\Rechnungen ohne Einzahlungsschein → Profil: default
  • \192.168.01.20\data\Vorauszahlungen → Profil: prepay
  • profiles.json:
  • default → IBAN: CH72 0000 0001 0002 0003 4
  • prepay → IBAN: CH72 0000 0001 0002 0005
    (WICHTIG: index.js MUSS das Flag –profile auswerten und anhand profiles.json die IBAN setzen.)
  • Optional anpassen (Dateinamen, Tages-Unterordner etc.) in Watch-OperaQR.ps1:
  • $FileNamePattern (z. B. ‚{Invoice}‘ oder ‚{Invoice} – {Debtor}‘)
  • $DailySubfolders = $true/$false

6) OPERA (PMS) – DRUCKERZUWEISUNG „RECHNUNGEN“

  • In OPERA an der Position „Rechnungen“ den Drucker ClawPDF hinterlegen,
    damit neu erzeugte Rechnungen automatisch als PDF im Hotfolder landen
    (z. B. J:\\Vorauszahlungen).

7) CLAWPDF 0.8.4 – INSTALLATION & INI-IMPORT

  1. ClawPDF 0.8.4 installieren (Setup-EXE).
  2. ClawPDF einmal starten (damit %AppData%\clawPDF\ existiert).
  3. Eigene INI nach
    %AppData%\clawPDF\settings.ini
    kopieren/überschreiben (z. B. Profil „Druck nach dem Speichern“ (PrintGuid) mit AutoSave nach J:\Rechnungen ohne Einzahlungsschein).

8) GEPLANTE AUFGABE (TASK SCHEDULER)

WICHTIGER HINWEIS (Lock-Screen / Abmeldung):

  • Standardmäßig laufen Aufgaben nicht weiter, wenn der Benutzer abgemeldet ist
    oder der Bildschirm gesperrt ist. Stelle daher in der Aufgabenplanung ein:
    Unabhängig von der Benutzeranmeldung ausführen“ und hinterlege Anmeldedaten.
  • Verwende nach Möglichkeit ein dediziertes Dienst-/Aufgaben-Konto (mit minimal nötigen Rechten
    und Zugriff auf die UNC-Shares).

Schnell-Setup in PowerShell (1-Minuten-Intervall):

$taskName = ‚OperaQR Auto Append‘
$psFile = «$env:USERPROFILE\Documents\qr-scan-append\Watch-OperaQR.ps1»
$action = New-ScheduledTaskAction -Execute ‚powershell.exe‘ -Argument «-ExecutionPolicy Bypass -NoProfile -File "$psFile«»
$trigger = New-ScheduledTaskTrigger -Once (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 1) -RepetitionDuration (New-TimeSpan -Days 99999)
$principal= New-ScheduledTaskPrincipal -UserId «$env:USERNAME» -RunLevel Highest -LogonType InteractiveOrPassword
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Description ‚Scan Opera-Folders (incl. Vorauszahlung) and append Swiss-QR‘ -Force

9) SCHNELLBEFEHLE (REFERENCE)

Policies & Unblock

Set-ExecutionPolicy -Scope CurrentUser RemoteSigned -Force
Unblock-File «$env:USERPROFILE\Documents\qr-scan-append*.ps1»
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned

Versionen prüfen

npm -v
node -v

Poppler

cd ‚C:\Program Files (x86)\poppler-24.08.0\Library\bin‘
.\pdftoppm.exe -v
setx PATH «$env:Path;C:\Program Files (x86)\poppler-24.08.0\Library\bin»

Dependencies

cd «$env:USERPROFILE\Documents\qr-scan-append»
npm install

10) TROUBLESHOOTING (KURZ)

  • Poppler nicht gefunden → Terminal neu öffnen (wegen setx) oder PATH im Skript prüfen.
  • IBAN in „Vorauszahlung“ ist trotzdem CH72 → index.js prüft evtl. –profile nicht. Profil‑fähige index.js verwenden.
  • Datei gesperrt/noch im Druck → $WaitStableSec / $MaxWaitSec in Watch-OperaQR.ps1 erhöhen.
  • Aufgabe startet nicht → „Unabhängig von der Benutzeranmeldung ausführen“, Konto/Passwort prüfen, Rechte auf UNC.

11) Testlauf

Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
cd «$env:USERPROFILE\Documents\qr-scan-append»
.\Watch-OperaQR.ps1 -SourceDir '\\192.168.01.20\data\Rechnungen ohne Einzahlungsschein'
-ProjectDir «$env:USERPROFILE\Documents\qr-scan-append» `
-OutputDir ‚\192.168.01.20\data\Rechnungen ohne Einzahlungsschein‘

12) Testlauf

-ExecutionPolicy Bypass -NoProfile -File «C:\Users\\Documents\qr-scan-append\Watch-OperaQR.ps1»

Hinweis: Swiss-QR ist offiziell nur mit IBAN CH zulässig. Für Vorauszahlung ist im Profil beispielhaft LI28… hinterlegt (passt also).

13) PowerShell Watch-OperaQR.PS1

param(
[string]$SourceDir = ‚\192.168.01.20\data\Rechnungen ohne Einzahlungsschein‘,
[string]$SourcePattern = ‚*.pdf‘, # alle PDFs
[string]$ProjectDir = «$env:USERPROFILE\Documents\qr-scan-append»,
[string]$OutputDir = ‚\192.168.01.20\data\Rechnungen ohne Einzahlungsschein‘,
[string]$ProcessedDir = ‚\192.168.01.20\data\Rechnungen ohne Einzahlungsschein\processed‘,
[string]$CreatedDir = ‚\192.168.01.20\data\Rechnungen ohne Einzahlungsschein\erstellte Rechnungen‘,
[string]$ManualAmount = », # z.B. 6336.50 (leer = auto)
[string]$FileNamePattern = ‚{Invoice}‘, # Standard: Rechnungsnummer
[bool]$DailySubfolders = $true, # Unterordner pro Tag
[int]$WaitStableSec = 3,
[int]$MaxWaitSec = 60
)

function Write-Log($msg, [ConsoleColor]$Color = [ConsoleColor]::Gray) {
$stamp = (Get-Date).ToString(‚yyyy-MM-dd HH:mm:ss‘)
$line = «[{0}] {1}» -f $stamp, $msg
Write-Host $line -ForegroundColor $Color
if (-not (Test-Path $OutputDir)) { New-Item -ItemType Directory -Path $OutputDir | Out-Null }
if (-not (Test-Path $ProcessedDir)) { New-Item -ItemType Directory -Path $ProcessedDir | Out-Null }
if (-not (Test-Path $CreatedDir)) { New-Item -ItemType Directory -Path $CreatedDir | Out-Null }
Add-Content -Path (Join-Path $OutputDir ‚operaqr.log‘) -Value $line
}

function Ensure-NodeDeps {
if (-not (Test-Path $ProjectDir)) { throw «ProjectDir nicht gefunden: $ProjectDir» }
Push-Location $ProjectDir
if (-not (Test-Path ’node_modules‘)) {
Write-Log «npm install (einmalig)…» DarkGray
& npm install –silent | Out-Null
}
Pop-Location
}

function Get-FileStable($path, $waitSec, $maxWait) {
$elapsed = 0
$lastLen = -1
while ($elapsed -lt $maxWait) {
if (-not (Test-Path $path)) { Start-Sleep -Seconds 1; $elapsed++; continue }
try {
$len = (Get-Item $path).Length
if ($len -gt 0 -and $len -eq $lastLen) { return $true }
$lastLen = $len
Start-Sleep -Seconds $waitSec
$elapsed += $waitSec
} catch {
Start-Sleep -Seconds 1
$elapsed++
}
}
return $false
}

function Sanitize-Name($s) {
if (-not $s) { return «Unbekannt» }
$s = $s -replace ‚[^\w-. ]‘,»
$s = $s.Trim()
if ($s.Length -gt 120) { $s = $s.Substring(0,120) }
return $s
}

function Expand-Pattern($pattern, $debtor, $invoice, $ts) {
if (-not $pattern) { $pattern = ‚{Invoice}‘ }
if ([string]::IsNullOrWhiteSpace($invoice)) { $invoice = «RG-$ts» }
$map = @{
‚{Debtor}‘ = (Sanitize-Name($debtor))
‚{Invoice}‘ = (Sanitize-Name($invoice))
‚{Timestamp}‘ = $ts
}
$out = $pattern
foreach ($k in $map.Keys) { $out = $out -replace [regex]::Escape($k), [regex]::Escape($map[$k]).Replace(‚\‘,’\‘) }
if ([string]::IsNullOrWhiteSpace($out)) { $out = «RG-$ts» }
return (Sanitize-Name($out))
}

function Next-AvailableName($dir, $baseName) {
$name = «$baseName.pdf»
$path = Join-Path $dir $name
$i = 2
while (Test-Path $path) {
$name = («{0} ({1}).pdf» -f $baseName, $i)
$path = Join-Path $dir $name
$i++
if ($i -gt 99) { break }
}
return $path
}

function TryExtractInvoiceFromText($pdfPath) {
try {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = «pdftotext»
$psi.Arguments = «-nopgbrk -layout -q "$pdfPath» -«
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$p = [System.Diagnostics.Process]::Start($psi)
$p.WaitForExit(15000) | Out-Null
$txt = $p.StandardOutput.ReadToEnd()
if (-not $txt) { return «» }

# 1) STRIKTE Regel: Zahl auf derselben Zeile wie RECHNUNGSKOPIE oder RECHNUNG
$lines = $txt -split "`r?`n"
foreach ($ln in $lines) {
  if ($ln -match '(?i)\b(RECHNUNGSKOPIE|RECHNUNG)\b') {
    # Nimm die längste Ziffernfolge (4-12 Ziffern) auf derselben Zeile
    $ms = [regex]::Matches($ln, '\b([0-9]{4,12})\b')
    if ($ms.Count -gt 0) {
      $cand = ($ms | Sort-Object { $_.Groups[1].Value.Length } -Descending | Select-Object -First 1).Groups[1].Value
      if ($cand) { return $cand }
    }
  }
}

# 2) Weitere übliche Muster (nur wenn 1) nichts fand)
$patterns = @(
  '(?im)Rechnungsnummer\s*[:#]?\s*([A-Za-z0-9\-/]{4,})',
  '(?im)Rechnung\s*Nr\.?\s*[:#]?\s*([A-Za-z0-9\-/]{4,})',
  '(?im)\bRG[_\-\s]?([0-9]{4,})\b',
  '(?im)\bRechnung[^\r\n]{0,40}?([0-9]{5,})\b'
)
foreach ($pat in $patterns) {
  $m = [regex]::Match($txt, $pat)
  if ($m.Success -and $m.Groups.Count -gt 1) {
    $val = $m.Groups[1].Value.Trim()
    if ($val) { return $val }
  }
}
return ""

} catch {
return «»
}
}

function Process-One($srcPath) {
try {
if (-not (Test-Path $srcPath)) { return }
$fi = Get-Item $srcPath

Write-Log ("--- Verarbeite: {0} (Größe: {1} Bytes)" -f $srcPath, $fi.Length) Cyan
if (-not (Get-FileStable $srcPath $WaitStableSec $MaxWaitSec)) {
  Write-Log "Datei wurde nicht stabil innerhalb ${MaxWaitSec}s. Überspringe." Yellow
  return
}

# Arbeitskopie
$work = Join-Path $ProjectDir 'work'
if (-not (Test-Path $work)) { New-Item -ItemType Directory -Path $work | Out-Null }
$ts = (Get-Date).ToString('yyyyMMdd_HHmmss')
$dateFolder = (Get-Date).ToString('yyyy-MM-dd')
$localIn = Join-Path $work (("{0}_{1}.pdf" -f [IO.Path]::GetFileNameWithoutExtension($fi.Name), $ts))
Copy-Item $srcPath $localIn -Force
Write-Log "Arbeitskopie: $localIn" DarkGray

# Vorab: Rechnungsnummer per Text-Fallback (damit Node sie für Referenz nutzen kann)
$preInvoice = TryExtractInvoiceFromText $localIn
if ($preInvoice) { Write-Log ("Vorab erkannte Rechnungsnummer: {0}" -f $preInvoice) DarkGray }

# Ausgabedatei temporär (lokal)
$tmpOut = Join-Path $work ("out_{0}.pdf" -f $ts)

# Node-Aufruf
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "node"
$psi.WorkingDirectory = $ProjectDir
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError  = $true
$psi.UseShellExecute = $false

$args = @("index.js", $localIn, $tmpOut)
if ($preInvoice) { $args += $preInvoice }   # <-- an Node übergeben, damit in Referenz einfließt
if ($ManualAmount -and $ManualAmount.Trim().Length -gt 0) {
  if (-not $preInvoice) { $args += "" }     # Platzhalter, falls Invoice leer wäre
  $args += $ManualAmount.Trim()
}
$psi.Arguments = ($args | ForEach-Object { if ($_ -match '\s') { '"{0}"' -f $_ } else { $_ } }) -join ' '

Write-Log "Starte Node: $($psi.Arguments)" DarkGray
$p = [System.Diagnostics.Process]::Start($psi)
$p.WaitForExit()
$stdout = $p.StandardOutput.ReadToEnd()
$stderr = $p.StandardError.ReadToEnd()
if ($stdout) { Write-Log $stdout.Trim() DarkGray }
if ($stderr) { Write-Log $stderr.Trim() DarkYellow }

if ($p.ExitCode -ne 0 -or -not (Test-Path $tmpOut)) {
  Write-Log "Fehler beim Erzeugen (ExitCode $($p.ExitCode)). Überspringe." Red
  return
}

# Debitorname + Rechnungsnummer aus JSON
$jsonLine = ($stdout -split "`r?`n" | Where-Object { $_ -match '^\s*\{' } | Select-Object -Last 1)
$debtorName, $invoiceNo = "Unbekannt", ""
if ($jsonLine) {
  try {
    $obj = $jsonLine | ConvertFrom-Json
    if ($obj.debtor -and $obj.debtor.name) { $debtorName = [string]$obj.debtor.name }
    elseif ($obj.debtorName) { $debtorName = [string]$obj.debtorName }
    if ($obj.invoiceNo) { $invoiceNo = [string]$obj.invoiceNo }
  } catch { }
}
if (-not $invoiceNo) { $invoiceNo = $preInvoice }  # falls Node keine geliefert hat

$baseName = Expand-Pattern $FileNamePattern $debtorName $invoiceNo $ts

# Zielordner (mit Tagesordnern)
$createdTarget = $CreatedDir
$processedTarget = $ProcessedDir
if ($DailySubfolders) {
  $createdTarget = Join-Path $CreatedDir $dateFolder
  $processedTarget = Join-Path $ProcessedDir $dateFolder
}
if (-not (Test-Path $createdTarget))   { New-Item -ItemType Directory -Path $createdTarget   | Out-Null }
if (-not (Test-Path $processedTarget)) { New-Item -ItemType Directory -Path $processedTarget | Out-Null }

# Zuerst im OutputDir ablegen, dann in Created verschieben
$intermediate = Join-Path $OutputDir ("{0}.pdf" -f $baseName)
if (Test-Path $intermediate) {
  $intermediate = (Join-Path $OutputDir ("{0} ({1}).pdf" -f $baseName, (Get-Random -Minimum 2 -Maximum 9999)))
}
Move-Item $tmpOut $intermediate -Force
$finalPath = Next-AvailableName $createdTarget $baseName
Move-Item $intermediate $finalPath -Force
Write-Log ("Ergebnis abgelegt: {0}" -f $finalPath) Green

# Original in processed verschieben (mit Originalname + ts)
try {
  $procName = Join-Path $processedTarget (("{0}_{1}.pdf" -f [IO.Path]::GetFileNameWithoutExtension($fi.Name), $ts))
  Move-Item $srcPath $procName -Force
  Write-Log ("Original verschoben nach: {0}" -f $procName) DarkGray
} catch {
  Write-Log ("Hinweis: Original konnte nicht verschoben werden: " + $_.Exception.Message) DarkYellow
}

} catch {
Write-Log («Unerwarteter Fehler bei Datei ‚{0}‘: {1}» -f $srcPath, $_.Exception.Message) Red
}
}

————— MAIN —————-

try {
Ensure-NodeDeps

if (-not (Test-Path $SourceDir)) {
Write-Log «SourceDir existiert nicht: $SourceDir» Yellow
exit 0
}

$files = Get-ChildItem -LiteralPath $SourceDir -Filter $SourcePattern -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTimeUtc, Name
if (-not $files -or $files.Count -eq 0) {
Write-Log «Keine passenden Dateien gefunden für Pattern: $SourcePattern» DarkGray
exit 0
}

foreach ($f in $files) { Process-One $f.FullName }

Write-Log («Batch fertig. Verarbeitete Dateien: {0}» -f $files.Count) Gray
exit 0

} catch {
Write-Log («Unerwarteter Fehler (Batch): » + $_.Exception.Message) Red
exit 9
}

14) Install-OperaQRTask.ps1

param(
[int]$EveryMinutes = 1,
[string]$SourceDir = ‚\192.168.01.20\data\Rechnungen ohne Einzahlungsschein‘,
[string]$ProjectDir = «$env:USERPROFILE\Documents\qr-scan-append»,
[string]$OutputDir = ‚\192.168.01.20\data\Rechnungen ohne Einzahlungsschein‘,
[string]$ManualAmount = »
)
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$watch = Join-Path $here ‚Watch-OperaQR.ps1‘

Index.js Parameter für Englich Deutsch Rechnungsinhalt

// index.js — QR-Append (swissqrbill v4.x kompatibel) — v17.0 (DE/EN Parser)
// Node >=18 (getestet mit Node 22)

// ———- Imports ———-
import fs from ‚fs‘;
import path from ‚path‘;
import { PDFDocument } from ‚pdf-lib‘;
import PDFKit from ‚pdfkit‘;
import { SwissQRBill } from ’swissqrbill/pdf‘;
import yargs from ‚yargs‘;
import { hideBin } from ‚yargs/helpers‘;

// ———- pdfjs-dist dynamisch laden (Legacy zuerst) ———-
let pdfjsMod;
try {
pdfjsMod = await import(‚pdfjs-dist/legacy/build/pdf.js‘);
} catch {
pdfjsMod = await import(‚pdfjs-dist/build/pdf.mjs‘);
}
let pdfjs = pdfjsMod?.default?.getDocument ? pdfjsMod.default : pdfjsMod;
const getPdfDocument = (bytes) => pdfjs.getDocument({ data: bytes, disableWorker: true });

// ———- Utils ———-
const norm = (s) => String(s || »)
.replace(/\u00A0/g, ‚ ‚)
.replace(/\r/g, »)
.replace(/[ \t]+/g, ‚ ‚)
.trim();

const clamp = (s, n) => (s ?? »).toString().replace(/\s+/g, ‚ ‚).trim().slice(0, n);
const clamp70 = (s) => clamp(s, 70);
const digitsOnly = (s) => (s ?? »).toString().replace(/\D+/g, »);

function normalizeIBAN(iban) {
return (iban || »).replace(/[^A-Za-z0-9]/g, »).replace(/(.{4})/g, ‚$1 ‚).trim();
}
function isQRIBAN(iban) {
const c = (iban || »).replace(/\s+/g, »).toUpperCase();
const part = c.slice(4, 9);
const n = parseInt(part, 10);
return c.startsWith(‚CH‘) && Number.isFinite(n) && n >= 30000 && n <= 31999;
}

// QRR Referenz aus Rechnungsnummer (27-stellig inkl. Prüfziffer)
function buildQRRFromInvoiceNo(inv) {
const raw = digitsOnly(inv);
const body = raw.padStart(26, ‚0‘);
const table = [0, 9, 4, 6, 8, 2, 7, 1, 3, 5];
let p = 0;
for (const ch of body) {
const d = ch.charCodeAt(0) – 48;
p = table[(p + d) % 10];
}
const cd = (10 – p) % 10;
return body + String(cd);
}

// SCOR Referenz aus Rechnungsnummer (RF..)
function buildSCORFromInvoiceNo(inv) {
const bban = String(inv).replace(/[^0-9A-Z]/gi, »).toUpperCase();
const toNum = (s) => s.replace(/[A-Z]/g, ch => String(ch.charCodeAt(0) – 55));
const numStr = toNum(bban + ‚RF00‘);
let rem = 0;
for (const ch of numStr) {
const d = ch.charCodeAt(0) – 48;
if (d < 0 || d > 9) continue;
rem = (rem * 10 + d) % 97;
}
const check = String(98 – rem).padStart(2, ‚0‘);
return ‚RF‘ + check + bban;
}

// ———- PDF-Text extrahieren ———-
async function extractFirstPageText(filePath) {
const data = new Uint8Array(fs.readFileSync(filePath));
const doc = await getPdfDocument(data).promise;
const page = await doc.getPage(1);
const content = await page.getTextContent();
const strings = content.items.map(i => i.str);
return strings.join(‚\n‘);
}

// ———- Parser (DE & EN) ———-
function parseMetaFromText(txt) {
const t = norm(txt);
const lines = String(txt || »).split(/\n+/).map(norm);

const meta = {
invoiceNo: undefined,
debtor: { name: undefined, address: undefined, buildingNumber: », zip: », city: », country: » },
amount: undefined
};

// Rechnungsnummer
let m = t.match(/RECHNUNG(?:SKOPIE)?\s+(\d{3,12})/i);
if (!m) m = t.match(/\bRechnungs?-?Nr.?\s[:#]?\s(\d{3,12})\b/i);
if (!m) m = t.match(/\bINVOICE\s+(\d{3,12})\b/i);
if (!m) m = t.match(/\bINFORMATION\s+INVOICE\b.*?\b(\d{3,12})\b/i);
if (m) meta.invoiceNo = m[1];

// Betrag
// EN & DE Varianten
let am =
t.match(/Total\s+CHF\s+([\d’’\s]+.\d{2})/i) ||
t.match(/Betrag\s+CHF\s+([\d’’\s]+.\d{2})/i) ||
t.match(/\bAmount\s+CHF\s+([\d’’\s]+.\d{2})/i);
if (am) {
meta.amount = parseFloat(am[1].replace(/[ ‚’]/g, »));
} else {
// Fallback: letzte 1–3 Zahlen mit Dezimalpunkt, nimm die größte
const nums = (t.match(/(\d{1,3}(?:[ ‚’]\d{3})*.\d{2})/g) || []).map(x => parseFloat(x.replace(/[ ‚’]/g, »)));
if (nums.length) meta.amount = Math.max(…nums);
}

// Debitorblock (Zeilen vor «RECHNUNG»/»INVOICE»)
const headRe = /(RECHNUNGSKOPIE|RECHNUNG\b|INFORMATION INVOICE|INVOICE\b)/i;
let idxHead = lines.findIndex(l => headRe.test(l));
if (idxHead < 0) idxHead = Math.min(lines.length, 25); // konservativ

const block = lines
.slice(0, idxHead)
.map(norm)
.filter(Boolean)
.slice(-6); // die letzten Zeilen direkt vor der Headline enthalten i.d.R. den Empfänger

if (block.length) {
// Heuristik: { name, street+no, zip+city, country? }
// Suche Country in der letzten/ vorletzten Zeile
const countryMap = {
Schweiz: ‚CH‘, Switzerland: ‚CH‘, ‚SWITZERLAND‘: ‚CH‘, CH: ‚CH‘,
Deutschland: ‚DE‘, Germany: ‚DE‘, DE: ‚DE‘,
Österreich: ‚AT‘, Oesterreich: ‚AT‘, Austria: ‚AT‘, AT: ‚AT‘,
France: ‚FR‘, Frankreich: ‚FR‘, FR: ‚FR‘,
Italia: ‚IT‘, Italien: ‚IT‘, Italy: ‚IT‘, IT: ‚IT‘
};

let country = '';
for (let i = block.length - 1; i >= Math.max(0, block.length - 2); i--) {
  const w = block[i].toUpperCase();
  for (const k of Object.keys(countryMap)) {
    if (w === k.toUpperCase()) { country = countryMap[k]; break; }
  }
  if (country) { block.splice(i, 1); break; }
}

// Suche ZIP+City
let zip = '', city = '';
let street = '';
let name = '';
for (let i = block.length - 1; i >= 0; i--) {
  const zc = block[i].match(/\b(\d{4,5})\s+(.+?)$/);
  if (zc) {
    zip = zc[1];
    city = clamp70(zc[2]);
    block.splice(i, 1);
    break;
  }
}
// Street+No (z.B. "Matt 754" / "Seedammstrasse 3")
for (let i = block.length - 1; i >= 0; i--) {
  if (/\d+\s*[A-Za-z]?$/.test(block[i])) {
    street = block[i];
    block.splice(i, 1);
    break;
  }
}
// Name = erste verbleibende Zeile des Blocks
if (block.length) name = block[0];

// Split Street/HouseNo
let address = '', house = '';
if (street) {
  const sm = street.match(/^(.*?)[ ,]+(\d+[A-Za-z]?)$/);
  if (sm) { address = sm[1]; house = sm[2]; }
  else { address = street; }
}

// Defaults / Aufbereitung
if (!country) country = 'CH';
if (name)      meta.debtor.name = clamp70(name);
if (address)   meta.debtor.address = clamp70(address);
if (house)     meta.debtor.buildingNumber = clamp(house, 20);
if (zip)       meta.debtor.zip = zip;
if (city)      meta.debtor.city = clamp70(city);
meta.debtor.country = country;

// Falls wir nur den Namen sicher haben, setze minimale Felder,
// damit swissqrbill nicht mit "address undefined" abbricht.
if (meta.debtor.name && (!meta.debtor.address || !meta.debtor.zip || !meta.debtor.city)) {
  // sehr defensiv: leere Pflichtfelder sanft auffüllen
  if (!meta.debtor.address) meta.debtor.address = 'n/a';
  if (!meta.debtor.zip)     meta.debtor.zip = '0000';
  if (!meta.debtor.city)    meta.debtor.city = 'n/a';
  if (!meta.debtor.country) meta.debtor.country = 'CH';
}

// Wenn trotzdem praktisch nichts da ist → Debitor weglassen
const haveMin =
  !!meta.debtor.name && !!meta.debtor.address && !!meta.debtor.zip && !!meta.debtor.city && !!meta.debtor.country;
if (!haveMin) meta.debtor = undefined;

}

return meta;
}

// ———- QR Bill Render (PDFKit + SwissQRBill) ———-
function renderQRBillBuffer(data) {
return new Promise((resolve, reject) => {
const doc = new PDFKit({ size: ‚A4‘ });
const chunks = [];
doc.on(‚data‘, (c) => chunks.push(c));
doc.on(‚end‘, () => resolve(Buffer.concat(chunks)));
doc.on(‚error‘, reject);

const qr = new SwissQRBill(data);
qr.attachTo(doc);
doc.end();

});
}

// ———- Original + Zahlteil mergen ———-
async function appendPaymentPart(originalBuf, paymentBuf) {
const src = await PDFDocument.load(originalBuf);
const add = await PDFDocument.load(paymentBuf);
const [p0] = await src.copyPages(add, [0]);
src.addPage(p0);
return await src.save();
}

// ———- Profile laden ———-
function loadProfile(name) {
const p = path.join(process.cwd(), ‚profiles.json‘);
let profiles = {
default: {
creditor: { name: ‚Hotel Seedamm AG‘, address: ‚Seedammstrasse‘, buildingNumber: ‚3‘, zip: ‚8808‘, city: ‚Pfäffikon SZ‘, country: ‚CH‘ },
account: ‚CH72 3000 0001 8700 0978 6‘
},
deposit: {
creditor: { name: ‚Hotel Seedamm AG‘, address: ‚Seedammstrasse‘, buildingNumber: ‚3‘, zip: ‚8808‘, city: ‚Pfäffikon SZ‘, country: ‚CH‘ },
account: ‚CH28 3250 0024 2547 1276‘
}
};
try {
if (fs.existsSync(p)) {
const js = JSON.parse(fs.readFileSync(p, ‚utf8‘));
profiles = { …profiles, …js };
}
} catch { /* ignore */ }

const prof = profiles[name] || profiles.default;
const account = prof.account || prof.iban;
const creditor = { …prof.creditor };
if (!creditor.address && prof.creditor?.street) creditor.address = prof.creditor.street;
if (!creditor.buildingNumber && prof.creditor?.houseNo) creditor.buildingNumber = prof.creditor.houseNo;
const refType = prof.refType || (isQRIBAN(account) ? ‚QRR‘ : ‚SCOR‘);
return { account: normalizeIBAN(account), creditor, refType };
}

// ———- CLI ———-
const argv = yargs(hideBin(process.argv))
.usage(’node index.js [invoiceNo] [amount] [–profile default|deposit]‘)
.option(‚profile‘, { type: ’string‘, default: ‚default‘ })
.demandCommand(2)
.help()
.parse();

const inPath = path.resolve(argv.[0]); const outPath = path.resolve(argv.[1]);
const profileName = argv.profile;
const profile = loadProfile(profileName);

const manualInvoice = argv.[2] ? String(argv.[2]) : undefined;
const manualAmount = argv.[3] ? Number(String(argv.[3]).replace(‚,‘, ‚.‘)) : undefined;

let meta = { invoiceNo: manualInvoice, amount: manualAmount, debtor: undefined };
try {
const txt = await extractFirstPageText(inPath);
const auto = parseMetaFromText(txt);
if (!meta.invoiceNo && auto.invoiceNo) meta.invoiceNo = auto.invoiceNo;
if (!meta.amount && auto.amount) meta.amount = auto.amount;
if (!meta.debtor && auto.debtor) meta.debtor = auto.debtor;
} catch (e) {
console.warn(‚[Warn] Text-Extraktion fehlgeschlagen:‘, e?.message || e);
}
if (!meta.invoiceNo) meta.invoiceNo = ‚0‘;

// Referenz
const reference = (profile.refType === ‚QRR‘)
? buildQRRFromInvoiceNo(meta.invoiceNo)
: buildSCORFromInvoiceNo(meta.invoiceNo);

// swissqrbill Daten (Achtung: bei v4.x liegt account IM creditor-Objekt)
const billData = {
currency: ‚CHF‘,
creditor: { …profile.creditor, account: profile.account },
reference,
additionalInformation: Rechnung ${meta.invoiceNo}
};
if (Number.isFinite(meta.amount) && meta.amount > 0) billData.amount = Number(meta.amount);
if (meta.debtor) billData.debtor = meta.debtor;

console.log([Info] Referenztyp: ${profile.refType}, Konto: ${profile.account}, Referenz: ${reference});
if (meta.debtor?.name) {
const extra = Number.isFinite(meta.amount) ? | Betrag: ${meta.amount} CHF : »;
console.log([Info] Debitor: ${meta.debtor.name}${extra});
}

try {
const zahlteil = await renderQRBillBuffer(billData);
let merged = false;
try {
const original = fs.readFileSync(inPath);
const outBuf = await appendPaymentPart(original, zahlteil);
fs.writeFileSync(outPath, outBuf);
merged = true;
} catch (mergeErr) {
console.warn(‚[Hinweis] Mergen fehlgeschlagen, schreibe nur Zahlteil. Grund:‘, mergeErr?.message || mergeErr);
fs.writeFileSync(outPath, zahlteil);
}
console.log(JSON.stringify({
ok: true,
invoiceNo: meta.invoiceNo ?? null,
amount: meta.amount ?? null,
debtor: meta.debtor ?? null,
reference,
merged,
onlyPaymentPart: !merged
}));
} catch (err) {
console.error(‚[Fehler] Zahlteil-Erzeugung: ‚, err?.message || err);
process.exit(1);
}

TaskAction (PowerShell, bypass policy)

$argList = «-NoProfile -ExecutionPolicy Bypass -File "$watch» -SourceDir "$SourceDir» -ProjectDir "$ProjectDir» -OutputDir "$OutputDir» -ManualAmount "$ManualAmount«»
$action = New-ScheduledTaskAction -Execute ‚powershell.exe‘ -Argument $argList

Trigger: Start in 1 min, dann Wiederholung alle N Minuten (10 Jahre Dauer)

$start = (Get-Date).AddMinutes(1)
$interval = New-TimeSpan -Minutes $EveryMinutes
$duration = New-TimeSpan -Days 3650
$trigger = New-ScheduledTaskTrigger -Once -At $start -RepetitionInterval $interval -RepetitionDuration $duration

Principal: aktueller User, sichtbar

$user = «$env:USERDOMAIN\$env:USERNAME»
$principal = New-ScheduledTaskPrincipal -UserId $user -RunLevel Highest -LogonType Interactive

Vorhandene Task ersetzen

if (Get-ScheduledTask -TaskName ‚OperaQR Auto Append‘ -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName ‚OperaQR Auto Append‘ -Confirm:$false
}

Register-ScheduledTask -TaskName ‚OperaQR Auto Append‘ -Action $action -Trigger $trigger -Principal $principal -Description ‚Alle PDFs (inkl. RECHNUNG/KOPIE) verarbeiten; Invoice-Fallback aus Text auf gleicher Zeile‘ -Force
Write-Host («Task ‚OperaQR Auto Append‘ registriert. Intervall: {0} Minute(n).» -f $EveryMinutes)

15) Uninstall-OperaQRTask.ps1

if (Get-ScheduledTask -TaskName ‚OperaQR Auto Append‘ -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName ‚OperaQR Auto Append‘ -Confirm:$false
Write-Host «Task entfernt: OperaQR Auto Append»
} else {
Write-Host «Task nicht gefunden.»
}

Das Archiv ist mit einem Password geschützt und kann via Mail info@ wissensbank.ch erfragt werden. Bitte mit Winrar auspacken.