diff --git a/stacks/monitoring/modules/monitoring/dashboards/uk-payslip.json b/stacks/monitoring/modules/monitoring/dashboards/uk-payslip.json index 9b0c2644..76a513eb 100644 --- a/stacks/monitoring/modules/monitoring/dashboards/uk-payslip.json +++ b/stacks/monitoring/modules/monitoring/dashboards/uk-payslip.json @@ -2033,6 +2033,131 @@ "rawSql": "WITH latest AS (SELECT DISTINCT ON (tax_year, employer_paye_ref) tax_year, employer_paye_ref, snapshot_date, gross_pay, income_tax, ni_contributions FROM hmrc_sync.tax_year_snapshot ORDER BY tax_year, employer_paye_ref, snapshot_date DESC), summed AS (SELECT tax_year, COALESCE(SUM(gross_pay), 0) AS sum_gross, COALESCE(SUM(income_tax), 0) AS sum_tax, COALESCE(SUM(national_insurance), 0) AS sum_ni FROM payslip_ingest.payslip GROUP BY tax_year) SELECT l.tax_year, l.employer_paye_ref, l.snapshot_date, l.gross_pay AS hmrc_gross, s.sum_gross AS computed_gross, (l.gross_pay - s.sum_gross) AS delta_gross, l.income_tax AS hmrc_tax, s.sum_tax AS computed_tax, (l.income_tax - s.sum_tax) AS delta_tax, l.ni_contributions AS hmrc_ni, s.sum_ni AS computed_ni, (l.ni_contributions - s.sum_ni) AS delta_ni FROM latest l LEFT JOIN summed s ON s.tax_year = l.tax_year ORDER BY l.tax_year DESC" } ] + }, + { + "id": 14, + "title": "Meta payroll: bank deposit vs payslip net pay", + "description": "Cross-check between ActualBudget bank deposits (META/FACEBOOK payee) and payslip net_pay. |delta| > \u00a350 flags likely parser or bank-sync drift. Synced daily 02:00 UTC.", + "type": "timeseries", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "payslips-pg" + }, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 160 + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "currencyGBP", + "custom": { + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 0, + "lineWidth": 2, + "pointSize": 6, + "showPoints": "always", + "spanNulls": false, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "deposit_sum" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "green" + } + }, + { + "id": "displayName", + "value": "Bank deposit (ActualBudget)" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "payslip_net_pay" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "blue" + } + }, + { + "id": "displayName", + "value": "Payslip net pay" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "delta" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "red" + } + }, + { + "id": "displayName", + "value": "Delta (deposit \u2212 payslip)" + }, + { + "id": "custom.drawStyle", + "value": "bars" + } + ] + } + ] + }, + "options": { + "legend": { + "calcs": [ + "last" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "payslips-pg" + }, + "rawSql": "WITH deposits AS (SELECT DATE_TRUNC('month', deposit_date)::date AS month_start, SUM(amount) AS deposit_sum FROM payslip_ingest.external_meta_deposits GROUP BY 1), payslip_net AS (SELECT DATE_TRUNC('month', pay_date)::date AS month_start, SUM(net_pay) AS payslip_net_pay FROM payslip_ingest.payslip GROUP BY 1) SELECT COALESCE(p.month_start, d.month_start) AS \"time\", d.deposit_sum, p.payslip_net_pay, COALESCE(d.deposit_sum, 0) - COALESCE(p.payslip_net_pay, 0) AS delta FROM deposits d FULL OUTER JOIN payslip_net p ON p.month_start = d.month_start WHERE $__timeFilter(COALESCE(p.month_start, d.month_start)) ORDER BY \"time\"", + "format": "time_series", + "refId": "A", + "rawQuery": true, + "editorMode": "code" + } + ] } ], "refresh": "5m", diff --git a/stacks/payslip-ingest/main.tf b/stacks/payslip-ingest/main.tf index 7e4d0006..8c313c25 100644 --- a/stacks/payslip-ingest/main.tf +++ b/stacks/payslip-ingest/main.tf @@ -32,7 +32,20 @@ resource "kubernetes_namespace" "payslip_ingest" { # Seed these manually in Vault before applying: # secret/paperless-ngx -> property `api_token` # secret/claude-agent-service -> property `api_bearer_token` -# secret/payslip-ingest -> property `webhook_bearer_token` +# secret/payslip-ingest -> properties: +# - `webhook_bearer_token` +# - `actualbudget_api_key` (same value as +# actualbudget-http-api-viktor random +# api-key — fetch via `kubectl get pods +# -n actualbudget -l +# app=actualbudget-http-api-viktor -o +# jsonpath={.items[0].spec.containers[0].env}` +# and grep API_KEY) +# - `actualbudget_encryption_password` +# (same as Viktor's budget password in +# secret/actualbudget/credentials[viktor]) +# - `actualbudget_budget_sync_id` +# (same as Viktor's sync_id) resource "kubernetes_manifest" "external_secret" { manifest = { apiVersion = "external-secrets.io/v1beta1" @@ -79,6 +92,27 @@ resource "kubernetes_manifest" "external_secret" { property = "webhook_bearer_token" } }, + { + secretKey = "ACTUALBUDGET_API_KEY" + remoteRef = { + key = "payslip-ingest" + property = "actualbudget_api_key" + } + }, + { + secretKey = "ACTUALBUDGET_ENCRYPTION_PASSWORD" + remoteRef = { + key = "payslip-ingest" + property = "actualbudget_encryption_password" + } + }, + { + secretKey = "ACTUALBUDGET_BUDGET_SYNC_ID" + remoteRef = { + key = "payslip-ingest" + property = "actualbudget_budget_sync_id" + } + }, ] } } @@ -288,6 +322,85 @@ resource "kubernetes_service" "payslip_ingest" { } } +# Daily sync of Meta payroll deposits from ActualBudget's http-api sidecar. +# Populates payslip_ingest.external_meta_deposits so Panel 14 can overlay bank +# deposits against payslip.net_pay — catches parser drift on net_pay. +resource "kubernetes_cron_job_v1" "actualbudget_payroll_sync" { + metadata { + name = "actualbudget-payroll-sync" + namespace = kubernetes_namespace.payslip_ingest.metadata[0].name + } + spec { + schedule = "0 2 * * *" + concurrency_policy = "Forbid" + successful_jobs_history_limit = 3 + failed_jobs_history_limit = 5 + starting_deadline_seconds = 300 + + job_template { + metadata { + labels = local.labels + } + spec { + backoff_limit = 1 + ttl_seconds_after_finished = 86400 + template { + metadata { + labels = local.labels + } + spec { + restart_policy = "OnFailure" + image_pull_secrets { + name = "registry-credentials" + } + container { + name = "sync" + image = local.image + command = ["python", "-m", "payslip_ingest", "sync-meta-deposits"] + + env_from { + secret_ref { + name = "payslip-ingest-secrets" + } + } + env_from { + secret_ref { + name = "payslip-ingest-db-creds" + } + } + + env { + name = "ACTUALBUDGET_HTTP_API_URL" + value = "http://budget-http-api-viktor.actualbudget.svc.cluster.local" + } + + resources { + requests = { + cpu = "50m" + memory = "128Mi" + } + limits = { + memory = "256Mi" + } + } + } + } + } + } + } + } + + lifecycle { + # KYVERNO_LIFECYCLE_V1 + ignore_changes = [spec[0].job_template[0].spec[0].template[0].spec[0].dns_config] + } + + depends_on = [ + kubernetes_manifest.external_secret, + kubernetes_manifest.db_external_secret, + ] +} + # Plan-time read of the ESO-created K8s Secret for Grafana datasource password. # First apply: -target=kubernetes_manifest.db_external_secret first so the Secret exists. data "kubernetes_secret" "payslip_ingest_db_creds" {