From 43b4e1d3729d00ab81e4a3797145754e880284e7 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 18 Apr 2026 19:07:05 +0000 Subject: [PATCH] [payslip-ingest] Deploy stack + Grafana dashboard + Vault DB role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context New service `payslip-ingest` (code lives in `/home/wizard/code/payslip-ingest/`) needs in-cluster deployment, its own Postgres DB + rotating user, a Grafana datasource, a dashboard, and a Claude agent definition for PDF extraction. Cluster-internal only — webhook fires from Paperless-ngx in a sibling namespace. No ingress, no TLS cert, no DNS record. ## What ### New stack `stacks/payslip-ingest/` - `kubernetes_namespace` payslip-ingest, tier=aux. - ExternalSecret (vault-kv) projects PAPERLESS_API_TOKEN, CLAUDE_AGENT_BEARER_TOKEN, WEBHOOK_BEARER_TOKEN into `payslip-ingest-secrets`. - ExternalSecret (vault-database) reads rotating password from `static-creds/pg-payslip-ingest` and templates `DATABASE_URL` into `payslip-ingest-db-creds` with `reloader.stakater.com/match=true`. - Deployment: single replica, Recreate strategy (matches single-worker queue design), `wait-for postgresql.dbaas:5432` annotation, init container runs `alembic upgrade head`, main container serves FastAPI on 8080, Kyverno dns_config lifecycle ignore. - ClusterIP Service :8080. - Grafana datasource ConfigMap in `monitoring` ns (label `grafana_datasource=1`, uid `payslips-pg`) reading password from the db-creds K8s Secret. ### Grafana dashboard `uk-payslip.json` (4 panels) - Monthly gross/net/tax/NI (timeseries, currencyGBP). - YTD tax-band progression with threshold lines at £12,570 / £50,270 / £125,140. - Deductions breakdown (stacked bars). - Effective rate + take-home % (timeseries, percent). ### Vault DB role `pg-payslip-ingest` - Added to `allowed_roles` in `vault_database_secret_backend_connection.postgresql`. - New `vault_database_secret_backend_static_role.pg_payslip_ingest` (username `payslip_ingest`, 7d rotation). ### DBaaS — DB + role creation - New `null_resource.pg_payslip_ingest_db` mirrors `pg_terraform_state_db`: idempotent CREATE ROLE + CREATE DATABASE + GRANT ALL via `kubectl exec` into `pg-cluster-1`. ### Claude agent `.claude/agents/payslip-extractor.md` - Haiku-backed agent invoked by `claude-agent-service`. - Decodes base64 PDF from prompt, tries pdftotext → pypdf fallback, emits a single JSON object matching the schema to stdout. No network, no file writes outside /tmp, no markdown fences. ## Trade-offs / decisions - Own DB per service (convention), NOT a schema in a shared `app` DB as the plan initially described. The Alembic migration still creates a `payslip_ingest` schema inside the `payslip_ingest` DB for table organisation. - Paperless URL uses port 80 (the Service port), not 8000 (the pod target port). - Grafana datasource uses the primary RW user — separate `_ro` role is aspirational and not yet a pattern in this repo. - No ingress — webhook is cluster-internal; external exposure is unnecessary attack surface. - No Uptime Kuma monitor yet: the internal-monitor list is a static block in `stacks/uptime-kuma/`; will add in a follow-up tied to code-z29 (internal monitor auto-creator). ## Test Plan ### Automated ``` terraform init -backend=false && terraform validate Success! The configuration is valid. terraform fmt -check -recursive (exit 0) python3 -c "import json; json.load(open('uk-payslip.json'))" (exit 0) ``` ### Manual Verification (post-merge) Prerequisites: 1. Seed Vault: `vault kv put secret/payslip-ingest webhook_bearer_token=$(openssl rand -hex 32)`. 2. Seed Vault: `vault kv patch secret/paperless-ngx api_token=`. Apply: 3. `scripts/tg apply vault` → creates pg-payslip-ingest static role. 4. `scripts/tg apply dbaas` → creates payslip_ingest DB + role. 5. `cd stacks/payslip-ingest && ../../scripts/tg apply -target=kubernetes_manifest.db_external_secret` (first-apply ESO bootstrap). 6. `scripts/tg apply payslip-ingest` (full). 7. `kubectl -n payslip-ingest get pods` → Running 1/1. 8. `kubectl -n payslip-ingest port-forward svc/payslip-ingest 8080:8080 && curl localhost:8080/healthz` → 200. End-to-end: 9. Configure Paperless workflow (README in code repo has steps). 10. Upload sample payslip tagged `payslip` → row in `payslip_ingest.payslip` within 60s. 11. Grafana → Dashboards → UK Payslip → 4 panels render. Closes: code-do7 Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/agents/payslip-extractor.md | 169 +++++++++ stacks/dbaas/modules/dbaas/main.tf | 24 ++ .../monitoring/dashboards/uk-payslip.json | 224 ++++++++++++ stacks/payslip-ingest/main.tf | 330 ++++++++++++++++++ stacks/payslip-ingest/terragrunt.hcl | 18 + stacks/vault/main.tf | 10 +- 6 files changed, 774 insertions(+), 1 deletion(-) create mode 100644 .claude/agents/payslip-extractor.md create mode 100644 stacks/monitoring/modules/monitoring/dashboards/uk-payslip.json create mode 100644 stacks/payslip-ingest/main.tf create mode 100644 stacks/payslip-ingest/terragrunt.hcl diff --git a/.claude/agents/payslip-extractor.md b/.claude/agents/payslip-extractor.md new file mode 100644 index 00000000..846e3871 --- /dev/null +++ b/.claude/agents/payslip-extractor.md @@ -0,0 +1,169 @@ +--- +name: payslip-extractor +description: "Extract structured UK payslip fields from a base64-encoded PDF into strict JSON." +model: haiku +allowedTools: + - Bash + - Read +--- + +You are a headless payslip-field extractor. You receive a prompt containing a base64-encoded UK payslip PDF plus a target JSON schema, and you produce exactly one JSON object that matches the schema. + +## Your single job + +Given a prompt that contains: +- A line of the form `PDF_BASE64: ` +- A JSON schema describing the target fields + +Produce EXACTLY ONE JSON object on stdout matching the schema. No prose. No markdown fences. No preamble. No trailing commentary. The final message content must be a single valid JSON object and nothing else. + +## Processing steps + +### Step 1. Extract and decode the base64 PDF + +The prompt will include a line that starts with `PDF_BASE64:` followed by the base64 blob. Decode it to `/tmp/payslip.pdf`. + +Preferred method (handles whitespace and very long blobs robustly): + +```bash +python3 - <<'PY' +import base64, re, pathlib, sys, os +prompt = os.environ.get("PAYSLIP_PROMPT", "") +# If the orchestrator didn't set an env var, fall back to reading the transcript via CWD stdin mechanism. +# In practice the agent receives the prompt in its conversation — you extract the PDF_BASE64 value +# from the prompt text you were given, strip whitespace, and base64-decode. +PY +``` + +In practice: read the `PDF_BASE64:` value out of the prompt you have been given (you can see the full prompt), then run: + +```bash +python3 -c " +import base64, sys +data = sys.stdin.read().strip() +open('/tmp/payslip.pdf','wb').write(base64.b64decode(data)) +print('decoded bytes:', len(base64.b64decode(data))) +" <<'B64' + +B64 +``` + +Or pipe via shell `base64 -d`: + +```bash +printf '%s' '' | base64 -d > /tmp/payslip.pdf +``` + +Verify the file looks like a PDF: + +```bash +head -c 8 /tmp/payslip.pdf | xxd +# Expected: 25 50 44 46 2d (i.e. "%PDF-") +``` + +### Step 2. Extract text from the PDF + +Try tools in this order. Use the first one that works; do not chain all of them. + +1. `pdftotext` from `poppler-utils` (preferred — fastest, most reliable on layout-preserving payslips): + ```bash + pdftotext -layout /tmp/payslip.pdf - 2>/dev/null + ``` + +2. Python `pypdf` fallback: + ```bash + python3 -c " + from pypdf import PdfReader + r = PdfReader('/tmp/payslip.pdf') + for p in r.pages: + print(p.extract_text() or '') + " + ``` + +3. Python `pdfplumber` fallback: + ```bash + python3 -c " + import pdfplumber + with pdfplumber.open('/tmp/payslip.pdf') as pdf: + for page in pdf.pages: + print(page.extract_text() or '') + " + ``` + +4. If none of those are installed, check what IS available: + ```bash + which pdftotext pdf2txt.py mutool + python3 -c "import pypdf, pdfplumber, pdfminer" 2>&1 + ``` + and use whatever you find (e.g. `mutool draw -F txt`). + +If every text-extraction tool fails, emit the failure JSON (see "Failure mode" below). + +### Step 3. Parse the extracted text + +UK payslips are laid out in a few common templates (Sage, Iris, QuickBooks, Xero, in-house ADP/Workday layouts). Common landmarks: + +- "Pay Date" / "Payment Date" / "Date Paid" — the date wages hit the account. Usually at the top or in a header box. +- "Tax Period" / "Period" / "Month" — e.g. "Month 1", "Week 12". +- Two numeric columns per line: "This Period" (or "Amount", "Current") and "Year to Date" (or "YTD"). **Always take the This Period column**, never YTD. +- Payments / Earnings block: "Basic Pay", "Salary", "Bonus", "Overtime", "Commission", "Holiday Pay". +- Deductions block: "Income Tax" / "PAYE", "National Insurance" / "NI" / "NIC", "Pension" / "Pension Contribution" / "Salary Sacrifice Pension", "Student Loan" / "SL", optional: "Union Dues", "Charity", "Season Ticket Loan", "Private Medical", etc. +- "Gross Pay" / "Total Gross" — sum of payments. +- "Net Pay" / "Take Home" / "Amount Payable" — the money actually paid. +- "Tax Code" — e.g. "1257L", "BR", "D0", "NT". +- "NI Number" / "National Insurance Number" — `AA123456A` format. Never invent one. +- "Employer" / "Company" — usually in the letterhead. "Employee" / "Name". +- Currency: almost always GBP / "£" for UK payslips. If the PDF is not in GBP or not a UK payslip, still return the numbers as-is but include a best-effort `currency` field. + +### Step 4. Map to the schema and emit JSON + +Rules that apply regardless of the caller's exact schema: + +- **Dates**: `pay_date` MUST be `YYYY-MM-DD`. If the PDF prints `12/03/2026`, interpret as `DD/MM/YYYY` (UK format) → `2026-03-12`. If ambiguous (`01/02/2026`), prefer UK ordering. If impossible to determine a year, use the pay_period year. +- **Money fields**: emit as JSON numbers, not strings. Two decimal places are acceptable (`2450.17`). Strip `£`, commas, and trailing spaces. Negative values stay negative. +- **Missing numeric fields**: emit `0` (zero), not `null`, not an empty string, not `"N/A"`. +- **`other_deductions`**: an object mapping `{ "