From c505ae4d504be901bc452afd624ae812c555d818 Mon Sep 17 00:00:00 2001 From: kua-deploy-split Date: Thu, 21 May 2026 18:53:02 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20commit=20=E2=80=94=20kua-cash?= =?UTF-8?q?ier=20payment=20confirmation=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot of /home/kavi/apps/kua-cashier on gal (also identical to /root/apps/kua-cashier on Bruno per sha256 audit 2026-05-21). Source previously lived as an ungit-managed working tree on both servers; this commit makes it deployable via kua-deploy + release-app. --- .gitignore | 3 + Dockerfile | 14 + bci.js | 71 ++++ db.js | 24 ++ dispatcher.js | 81 ++++ docker-compose.yml | 66 ++++ kua.json | 8 + matcher.js | 59 +++ migrations/001_initial.sql | 81 ++++ package-lock.json | 769 +++++++++++++++++++++++++++++++++++++ package.json | 15 + server.js | 429 +++++++++++++++++++++ util.js | 27 ++ 13 files changed, 1647 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 bci.js create mode 100644 db.js create mode 100644 dispatcher.js create mode 100644 docker-compose.yml create mode 100644 kua.json create mode 100644 matcher.js create mode 100644 migrations/001_initial.sql create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server.js create mode 100644 util.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e8157a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0aff5a9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM node:22-alpine + +RUN apk add --no-cache curl + +WORKDIR /app + +COPY package.json ./ +RUN npm install --production + +COPY . . + +EXPOSE 3500 + +CMD ["node", "server.js"] diff --git a/bci.js b/bci.js new file mode 100644 index 0000000..048ab94 --- /dev/null +++ b/bci.js @@ -0,0 +1,71 @@ +// BCI api-business-notifications client +// Sandbox: https://apipartner.bci.cl/sandbox/v2/api-business-notifications +// Prod: https://apipartner.bci.cl/v2/api-business-notifications + +function baseUrl() { + return process.env.BCI_SANDBOX === '1' + ? 'https://apipartner.bci.cl/sandbox/v2/api-business-notifications' + : 'https://apipartner.bci.cl/v2/api-business-notifications'; +} + +function bciHeaders(subscriptionKey, extra = {}) { + return { + 'Content-Type': 'application/json', + 'x-apikey': subscriptionKey, + 'Referer': 'https://apipartner.bci.cl/', + ...extra, + }; +} + +/** + * Register our callback URL with BCI. + * webhookSecret is a key WE choose — BCI echoes it back in every notification + * payload as APIKey so we can verify the webhook came from BCI. + */ +export async function createSubscription({ organizationName, account, rut, checkDigit, callbackUrl, subscriptionKey, webhookSecret }) { + // Embed secret as ?token=... so BCI calls us with it; BCI does not echo APIKey in webhook bodies + const secureCallbackUrl = webhookSecret + ? `${callbackUrl}?token=${encodeURIComponent(webhookSecret)}` + : callbackUrl; + + const res = await fetch(`${baseUrl()}/subscription`, { + method: 'POST', + headers: bciHeaders(subscriptionKey), + body: JSON.stringify({ + OrganizationName: organizationName, + Account: account, + RUT: rut, + CheckDigit: checkDigit, + URLCallback: secureCallbackUrl, + APIKey: webhookSecret, + }), + }); + const text = await res.text(); + if (!res.ok) throw new Error(`BCI createSubscription ${res.status}: ${text}`); + try { return JSON.parse(text); } catch { return { raw: text }; } +} + +/** + * Trigger a test notification to our callback URL. + * Useful after subscription setup to confirm the full flow works and + * to discover the exact notification payload shape BCI sends. + */ +export async function simulateNotification({ callbackUrl, amount, movementType = '1', subscriptionKey, webhookSecret }) { + const secureCallbackUrl = webhookSecret + ? `${callbackUrl}?token=${encodeURIComponent(webhookSecret)}` + : callbackUrl; + + const res = await fetch(`${baseUrl()}/simulations`, { + method: 'POST', + headers: bciHeaders(subscriptionKey), + body: JSON.stringify({ + URLCallback: secureCallbackUrl, + Amount: String(amount), + MovementType: movementType, + APIKey: webhookSecret, + }), + }); + const text = await res.text(); + if (!res.ok) throw new Error(`BCI simulateNotification ${res.status}: ${text}`); + try { return JSON.parse(text); } catch { return { raw: text }; } +} diff --git a/db.js b/db.js new file mode 100644 index 0000000..5efecc7 --- /dev/null +++ b/db.js @@ -0,0 +1,24 @@ +import pg from 'pg'; +import { readFile } from 'fs/promises'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const pool = new pg.Pool({ + connectionString: process.env.KUA_CASHIER_DB_URL, + max: 10, +}); + +export async function migrate() { + const sql = await readFile(join(__dirname, 'migrations/001_initial.sql'), 'utf-8'); + await pool.query(sql); +} + +export function query(text, params) { + return pool.query(text, params); +} + +export function getPool() { + return pool; +} diff --git a/dispatcher.js b/dispatcher.js new file mode 100644 index 0000000..9b780b8 --- /dev/null +++ b/dispatcher.js @@ -0,0 +1,81 @@ +import { query } from './db.js'; +import { newId, hmacSign } from './util.js'; + +const CALLBACK_TIMEOUT_MS = 10_000; +const MAX_ATTEMPTS = 3; + +/** + * Mark intent + inbound as matched in a transaction, then fire the callback. + * Callback failures are logged but don't roll back the match — the payment + * happened regardless; downstream can poll /intent/:id as fallback. + */ +export async function confirmAndDispatch(intent, inbound, confidence) { + const now = new Date().toISOString(); + + await query( + `UPDATE payment_intents + SET status = 'matched', matched_at = $1, inbound_id = $2 + WHERE id = $3`, + [now, inbound.id, intent.id] + ); + await query( + `UPDATE inbound_payments + SET status = 'matched', intent_id = $1 + WHERE id = $2`, + [intent.id, inbound.id] + ); + + await fireCallback(intent, inbound, confidence); +} + +async function fireCallback(intent, inbound, confidence) { + const payload = { + event: 'payment.confirmed', + intent_id: intent.id, + source_app: intent.source_app, + inbound_id: inbound.id, + confidence, + amount: inbound.amount, + nominal_amount: intent.nominal_amount, + exact_amount: intent.exact_amount, + jitter: intent.jitter, + sender_rut: inbound.sender_rut ?? null, + received_at: inbound.received_at, + reference_code: intent.reference_code, + metadata: intent.metadata ?? null, + }; + + const body = JSON.stringify(payload); + const headers = { 'Content-Type': 'application/json' }; + if (intent.callback_secret) { + headers['X-Cashier-Signature'] = `sha256=${hmacSign(intent.callback_secret, body)}`; + } + + let lastStatus = null; + let attempts = 0; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + attempts = attempt; + try { + const res = await fetch(intent.callback_url, { + method: 'POST', + headers, + body, + signal: AbortSignal.timeout(CALLBACK_TIMEOUT_MS), + }); + lastStatus = res.status; + if (res.ok) break; + } catch { + lastStatus = 0; + } + if (attempt < MAX_ATTEMPTS) { + await new Promise(r => setTimeout(r, attempt * 2000)); + } + } + + await query( + `INSERT INTO match_log (id, intent_id, inbound_id, confidence, callback_status, callback_attempts) + VALUES ($1, $2, $3, $4, $5, $6)`, + [newId(), intent.id, inbound.id, confidence, lastStatus, attempts] + ); +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..241e5aa --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,66 @@ +services: + kua-cashier-postgres: + image: postgres:16-alpine + container_name: kua-cashier-postgres + restart: unless-stopped + environment: + - POSTGRES_USER=kua_cashier + - POSTGRES_PASSWORD=${KUA_CASHIER_DB_PASSWORD:?KUA_CASHIER_DB_PASSWORD required} + - POSTGRES_DB=kua_cashier + volumes: + - kua-cashier-postgres-data:/var/lib/postgresql/data + networks: + - kua-services + healthcheck: + test: ["CMD-SHELL", "pg_isready -U kua_cashier -d kua_cashier"] + interval: 10s + timeout: 5s + retries: 5 + + kua-cashier: + build: . + container_name: kua-cashier + restart: unless-stopped + environment: + - NODE_ENV=production + - PORT=4200 + - KUA_CASHIER_DB_URL=postgresql://kua_cashier:${KUA_CASHIER_DB_PASSWORD}@kua-cashier-postgres:5432/kua_cashier + - KUA_CASHIER_ADMIN_TOKEN=${KUA_CASHIER_ADMIN_TOKEN:?KUA_CASHIER_ADMIN_TOKEN required} + - KUA_CASHIER_PUBLIC_URL=${KUA_CASHIER_PUBLIC_URL:-https://cashier.kua.cl} + - KUA_ALLOWED_NODES=${KUA_ALLOWED_NODES:-gal,bruno,genesis} + - BCI_SANDBOX=${BCI_SANDBOX:-1} + - BCI_SUBSCRIPTION_KEY=${BCI_SUBSCRIPTION_KEY:-} + - BCI_WEBHOOK_SECRET=${BCI_WEBHOOK_SECRET:-} + - BCI_ORGANIZATION_NAME=${BCI_ORGANIZATION_NAME:-} + - BCI_ACCOUNT_NUMBER=${BCI_ACCOUNT_NUMBER:-} + - BCI_ACCOUNT_RUT=${BCI_ACCOUNT_RUT:-} + - BCI_ACCOUNT_CHECK_DIGIT=${BCI_ACCOUNT_CHECK_DIGIT:-} + ports: + - "100.74.17.6:4200:4200" + volumes: + - /var/run/tailscale/tailscaled.sock:/var/run/tailscale/tailscaled.sock:ro + networks: + - kua-services + - production_proxy + depends_on: + kua-cashier-postgres: + condition: service_healthy + labels: + - "caddy=cashier.kua.cl" + - "caddy.handle=/bci/*" + - "caddy.handle.reverse_proxy={{upstreams 4200}}" + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:4200/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +networks: + production_proxy: + external: true + kua-services: + external: true + +volumes: + kua-cashier-postgres-data: diff --git a/kua.json b/kua.json new file mode 100644 index 0000000..02e1baa --- /dev/null +++ b/kua.json @@ -0,0 +1,8 @@ +{ + "name": "kua-cashier", + "type": "docker-compose", + "compose_file": "docker-compose.yml", + "vault_project": "kua-cashier", + "vault_env": "prod", + "health_url": "http://100.74.17.6:4200/health" +} diff --git a/matcher.js b/matcher.js new file mode 100644 index 0000000..860e4d9 --- /dev/null +++ b/matcher.js @@ -0,0 +1,59 @@ +import { query } from './db.js'; +import { normalizeRut } from './util.js'; + +// Extract a 6-char uppercase alphanumeric code from transfer description +function extractCode(description) { + if (!description) return null; + const m = description.toUpperCase().match(/\b([A-Z0-9]{6})\b/); + return m ? m[1] : null; +} + +/** + * Try to match an inbound payment against a pending intent. + * Returns { intent, confidence } or null. + * + * Priority: + * 1. Reference code in description — strongest signal, unambiguous + * 2. Exact amount + sender RUT — reliable when RUT is available + * 3. Exact amount only (same account) — weaker, flagged as 'amount_only' + */ +export async function matchInbound(inbound) { + // 1. Reference code + const code = extractCode(inbound.description); + if (code) { + const { rows } = await query( + `SELECT * FROM payment_intents WHERE reference_code = $1 AND status = 'pending'`, + [code] + ); + if (rows[0]) return { intent: rows[0], confidence: 'reference_code' }; + } + + // 2. Exact amount + sender RUT + const senderRut = normalizeRut(inbound.sender_rut); + if (senderRut) { + const { rows } = await query( + `SELECT * FROM payment_intents + WHERE exact_amount = $1 + AND status = 'pending' + AND ( + sender_rut IS NULL + OR replace(lower(sender_rut), '.', '') = $2 + ) + AND ($3::text IS NULL OR account_id = $3)`, + [inbound.amount, senderRut, inbound.account_id ?? null] + ); + if (rows[0]) return { intent: rows[0], confidence: 'exact_amount_rut' }; + } + + // 3. Exact amount on same account (weakest — no RUT confirmation) + if (inbound.account_id) { + const { rows } = await query( + `SELECT * FROM payment_intents + WHERE exact_amount = $1 AND account_id = $2 AND status = 'pending'`, + [inbound.amount, inbound.account_id] + ); + if (rows[0]) return { intent: rows[0], confidence: 'amount_only' }; + } + + return null; +} diff --git a/migrations/001_initial.sql b/migrations/001_initial.sql new file mode 100644 index 0000000..af7cc09 --- /dev/null +++ b/migrations/001_initial.sql @@ -0,0 +1,81 @@ +-- Monitored bank accounts (accounts we own and watch for incoming transfers) +CREATE TABLE IF NOT EXISTS accounts ( + id TEXT PRIMARY KEY, + bank TEXT NOT NULL, -- 'bci', 'santander', 'bancochile', etc. + account_number TEXT NOT NULL, + owner_name TEXT NOT NULL, + owner_rut TEXT NOT NULL, -- normalized: 12345678-9 + check_digit TEXT, -- BCI requires separate check digit + api_key TEXT, -- BCI subscription key + webhook_secret TEXT, -- key we embed in BCI subscription; BCI echoes it back in webhooks + bci_subscription_id TEXT, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (bank, account_number) +); + +-- Expected payments registered by downstream services +CREATE TABLE IF NOT EXISTS payment_intents ( + id TEXT PRIMARY KEY, + source_app TEXT NOT NULL, -- 'muralla', 'rover', etc. + account_id TEXT NOT NULL REFERENCES accounts(id), + nominal_amount INTEGER NOT NULL, -- what the customer "should" pay + exact_amount INTEGER NOT NULL, -- jittered: always <= nominal + jitter INTEGER NOT NULL DEFAULT 0, + sender_rut TEXT, -- expected sender RUT (match hint, optional) + reference_code TEXT NOT NULL UNIQUE, -- 6-char code; customer puts in transfer description + callback_url TEXT NOT NULL, + callback_secret TEXT, -- HMAC-SHA256 key for signing confirmations + status TEXT NOT NULL DEFAULT 'pending', -- pending | matched | expired | cancelled + metadata JSONB, -- passed through to callback payload + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + matched_at TIMESTAMPTZ, + inbound_id TEXT +); + +-- Unique constraint: no two live intents can share the same exact_amount on the same account +CREATE UNIQUE INDEX IF NOT EXISTS payment_intents_slot + ON payment_intents (exact_amount, account_id) + WHERE status = 'pending'; + +CREATE INDEX IF NOT EXISTS payment_intents_rut + ON payment_intents (sender_rut) + WHERE status = 'pending'; + +CREATE INDEX IF NOT EXISTS payment_intents_status + ON payment_intents (status, expires_at); + +-- Inbound transfers detected from any source +CREATE TABLE IF NOT EXISTS inbound_payments ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, -- 'bci_webhook' | 'bank_parser' | 'email' + account_id TEXT REFERENCES accounts(id), + amount INTEGER NOT NULL, -- whole pesos (CLP) + sender_rut TEXT, + description TEXT, -- transfer description / gloss + received_at TIMESTAMPTZ NOT NULL, -- when the bank says the transfer happened + detected_at TIMESTAMPTZ NOT NULL DEFAULT now(), + status TEXT NOT NULL DEFAULT 'unaccounted', -- unaccounted | matched | ignored + intent_id TEXT, + bank_ref TEXT, -- bank's own transaction ID for deduplication + raw JSONB NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS inbound_bank_ref + ON inbound_payments (bank_ref) + WHERE bank_ref IS NOT NULL; + +CREATE INDEX IF NOT EXISTS inbound_status ON inbound_payments (status); +CREATE INDEX IF NOT EXISTS inbound_detected ON inbound_payments (detected_at DESC); + +-- Match audit log +CREATE TABLE IF NOT EXISTS match_log ( + id TEXT PRIMARY KEY, + intent_id TEXT NOT NULL, + inbound_id TEXT NOT NULL, + confidence TEXT NOT NULL, -- 'reference_code' | 'exact_amount_rut' | 'amount_only' + callback_status INTEGER, + callback_attempts INTEGER DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..aad930e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,769 @@ +{ + "name": "kua-cashier", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kua-cashier", + "version": "1.0.0", + "dependencies": { + "fastify": "^5.0.0", + "pg": "^8.13.0" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.4.0.tgz", + "integrity": "sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.8.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", + "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/toad-cache": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.1.tgz", + "integrity": "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a964ff8 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "kua-cashier", + "version": "1.0.0", + "description": "Payment confirmation engine — matches inbound bank transfers to expected payment intents", + "type": "module", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "fastify": "^5.0.0", + "pg": "^8.13.0" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..fc2e602 --- /dev/null +++ b/server.js @@ -0,0 +1,429 @@ +import Fastify from 'fastify'; +import http from 'http'; +import { migrate, query } from './db.js'; +import { matchInbound } from './matcher.js'; +import { confirmAndDispatch } from './dispatcher.js'; +import { createSubscription, simulateNotification } from './bci.js'; +import { newId, genRefCode, parsePesos } from './util.js'; + +const PORT = parseInt(process.env.PORT ?? '3500', 10); +const ADMIN_TOKEN = process.env.KUA_CASHIER_ADMIN_TOKEN ?? ''; +const PUBLIC_URL = process.env.KUA_CASHIER_PUBLIC_URL ?? ''; // e.g. https://cashier.kua.cl +const ALLOWED_NODES = new Set((process.env.KUA_ALLOWED_NODES ?? 'gal,bruno,genesis').split(',').map(s => s.trim())); +const TAILSCALE_SOCKET = '/var/run/tailscale/tailscaled.sock'; +const DEV = process.env.NODE_ENV !== 'production'; + +// ─── BCI config (from env / kua-vault) ────────────────────────────────────── + +const BCI_CFG = { + subscriptionKey: process.env.BCI_SUBSCRIPTION_KEY ?? '', + webhookSecret: process.env.BCI_WEBHOOK_SECRET ?? '', + organizationName: process.env.BCI_ORGANIZATION_NAME ?? '', + accountNumber: process.env.BCI_ACCOUNT_NUMBER ?? '', + rut: process.env.BCI_ACCOUNT_RUT ?? '', + checkDigit: process.env.BCI_ACCOUNT_CHECK_DIGIT ?? '', +}; + +// ─── Fastify ───────────────────────────────────────────────────────────────── + +const fastify = Fastify({ logger: true }); + +// ─── Tailscale whois ───────────────────────────────────────────────────────── + +async function tailscaleWhois(remoteAddr) { + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(null), 2000); + const req = http.request( + { socketPath: TAILSCALE_SOCKET, path: `/localapi/v0/whois?addr=${encodeURIComponent(remoteAddr)}`, method: 'GET' }, + (res) => { + let data = ''; + res.on('data', c => { data += c; }); + res.on('end', () => { + clearTimeout(timeout); + try { + const p = JSON.parse(data); + resolve(p.Node ? { + hostname: p.Node.ComputedName ?? '', + tags: p.Node.Tags ?? [], + user: p.UserProfile?.LoginName ?? '', + } : null); + } catch { resolve(null); } + }); + } + ); + req.on('error', () => { clearTimeout(timeout); resolve(null); }); + req.end(); + }); +} + +function isAllowedNode(id) { + if (!id) return false; + if (id.tags?.includes('tag:admin')) return true; + return ALLOWED_NODES.has(id.hostname); +} + +// ─── Auth hook ─────────────────────────────────────────────────────────────── +// Public paths: /health, /bci/notify (has its own APIKey check) +// Everything else: Tailscale whois OR Bearer token + +fastify.addHook('onRequest', async (req, reply) => { + const { url } = req; + if (url === '/health' || url.startsWith('/bci/notify')) return; + + // Bearer token + const auth = req.headers.authorization; + if (auth?.startsWith('Bearer ') && ADMIN_TOKEN && auth.slice(7) === ADMIN_TOKEN) return; + + // Tailscale whois + const isLocal = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.ip) || req.ip.startsWith('172.'); + if (isLocal) { req.identity = { hostname: 'bruno', tags: ['tag:admin'] }; return; } + + if (!DEV) { + const id = await tailscaleWhois(`${req.ip}:${req.socket.remotePort ?? 0}`); + if (!id || !isAllowedNode(id)) { + return reply.code(403).send({ error: 'not authorized' }); + } + req.identity = id; + } +}); + +// ─── Routes: health ─────────────────────────────────────────────────────────── + +fastify.get('/health', async () => ({ ok: true, service: 'kua-cashier' })); + +// ─── Routes: BCI webhook ────────────────────────────────────────────────────── +// BCI payload fields (discovered from sandbox): +// idEvento, idContable, monto, fechaTransaccion, fechaNotificacion, +// rutCliente, dvCliente, tipoTransaccion, anulacion, numeroReintento, +// concepto.{ nombre, rut, dv, numeroCuenta, codigoBanco, nroOperacion, mensaje } +// +// Auth: secret embedded as ?token=... in the callback URL we register with BCI. +// BCI does NOT echo APIKey back in the webhook body. + +fastify.post('/bci/notify', async (req, reply) => { + const raw = req.body ?? {}; + fastify.log.info({ raw }, 'bci webhook received'); + + // Verify token embedded in callback URL query string + const token = req.query?.token ?? ''; + if (BCI_CFG.webhookSecret && token !== BCI_CFG.webhookSecret) { + fastify.log.warn('bci webhook: token mismatch'); + return reply.code(401).send({ error: 'invalid token' }); + } + + // Skip reversals + if (raw.anulacion === true) { + return reply.send({ ok: true, skipped: 'anulacion' }); + } + + // monto is an integer in BCI's payload + const amount = typeof raw.monto === 'number' ? raw.monto : parsePesos(raw.monto); + if (!amount || amount <= 0) { + return reply.code(400).send({ error: 'missing or invalid monto' }); + } + + // Sender RUT: rutCliente + dvCliente → "88777666-0" + const senderRut = raw.rutCliente + ? `${raw.rutCliente}-${raw.dvCliente ?? ''}`.replace(/-$/, '') + : null; + + // Reference code lives in concepto.mensaje (customer fills in transfer description) + const description = raw.concepto?.mensaje ?? null; + + const bankRef = raw.idEvento ?? null; + const receivedAt = raw.fechaTransaccion ?? raw.fechaNotificacion ?? new Date().toISOString(); + + const { rows: [account] } = await query( + `SELECT id FROM accounts WHERE bank = 'bci' AND active = true LIMIT 1` + ); + + const id = newId(); + try { + await query( + `INSERT INTO inbound_payments + (id, source, account_id, amount, sender_rut, description, received_at, bank_ref, raw) + VALUES ($1, 'bci_webhook', $2, $3, $4, $5, $6, $7, $8)`, + [id, account?.id ?? null, amount, senderRut, description, receivedAt, bankRef, JSON.stringify(raw)] + ); + } catch (err) { + if (err.code === '23505') { + fastify.log.info({ bankRef }, 'bci webhook: duplicate, skipping'); + return reply.send({ ok: true, skipped: 'duplicate' }); + } + throw err; + } + + const { rows: [inbound] } = await query(`SELECT * FROM inbound_payments WHERE id = $1`, [id]); + await runMatcher(inbound); + + // BCI expects this specific response shape (S003 sandbox warning goes away with it) + return reply.send({ Data: { Status: 'RECIBIDO' }, Links: {}, Meta: {} }); +}); + +// ─── Routes: generic inbound (bank-parser, email, etc.) ─────────────────────── + +fastify.post('/inbound/notify', async (req, reply) => { + const body = req.body ?? {}; + + const amount = parsePesos(body.amount ?? body.amountMinor); + if (!amount || amount <= 0) return reply.code(400).send({ error: 'amount required' }); + + // Resolve account_id from bank + account_number if provided + let accountId = body.account_id ?? null; + if (!accountId && body.bank && body.account_number) { + const { rows: [acc] } = await query( + `SELECT id FROM accounts WHERE bank = $1 AND account_number = $2`, + [body.bank, body.account_number] + ); + accountId = acc?.id ?? null; + } + + const bankRef = body.bank_ref ?? body.bankRef ?? null; + const id = newId(); + + try { + await query( + `INSERT INTO inbound_payments + (id, source, account_id, amount, sender_rut, description, received_at, bank_ref, raw) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + id, + body.source ?? 'bank_parser', + accountId, + amount, + body.sender_rut ?? body.senderRut ?? null, + body.description ?? null, + body.received_at ?? body.receivedAt ?? body.postedAt ?? new Date().toISOString(), + bankRef, + JSON.stringify(body), + ] + ); + } catch (err) { + if (err.code === '23505') return reply.send({ ok: true, skipped: 'duplicate' }); + throw err; + } + + const { rows: [inbound] } = await query(`SELECT * FROM inbound_payments WHERE id = $1`, [id]); + await runMatcher(inbound); + + return reply.send({ ok: true, inbound_id: id }); +}); + +// ─── Routes: payment intents ────────────────────────────────────────────────── + +fastify.post('/intent', async (req, reply) => { + const { source_app, account_id, nominal_amount, sender_rut, callback_url, callback_secret, expires_in = 86400, metadata } = req.body ?? {}; + + if (!source_app) return reply.code(400).send({ error: 'source_app required' }); + if (!account_id) return reply.code(400).send({ error: 'account_id required' }); + if (!nominal_amount || nominal_amount <= 0) return reply.code(400).send({ error: 'nominal_amount required' }); + if (!callback_url) return reply.code(400).send({ error: 'callback_url required' }); + + const { rows: [account] } = await query(`SELECT * FROM accounts WHERE id = $1 AND active = true`, [account_id]); + if (!account) return reply.code(404).send({ error: 'account not found' }); + + // Find lowest free slot: try nominal_amount-1, nominal_amount-2, ... + let exactAmount = null; + let jitter = 0; + for (let j = 0; j <= 50; j++) { + const candidate = nominal_amount - j; + if (candidate <= 0) break; + const { rows } = await query( + `SELECT 1 FROM payment_intents WHERE exact_amount = $1 AND account_id = $2 AND status = 'pending'`, + [candidate, account_id] + ); + if (rows.length === 0) { exactAmount = candidate; jitter = j; break; } + } + if (exactAmount === null) return reply.code(409).send({ error: 'no available amount slot — too many concurrent intents at this amount' }); + + // Generate unique reference code + let refCode; + for (let i = 0; i < 10; i++) { + const candidate = genRefCode(); + const { rows } = await query(`SELECT 1 FROM payment_intents WHERE reference_code = $1`, [candidate]); + if (rows.length === 0) { refCode = candidate; break; } + } + if (!refCode) return reply.code(500).send({ error: 'could not generate unique reference code' }); + + const id = newId(); + const expiresAt = new Date(Date.now() + expires_in * 1000).toISOString(); + + await query( + `INSERT INTO payment_intents + (id, source_app, account_id, nominal_amount, exact_amount, jitter, sender_rut, + reference_code, callback_url, callback_secret, metadata, expires_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, + [id, source_app, account_id, nominal_amount, exactAmount, jitter, sender_rut ?? null, + refCode, callback_url, callback_secret ?? null, metadata ? JSON.stringify(metadata) : null, expiresAt] + ); + + return reply.code(201).send({ + intent_id: id, + exact_amount: exactAmount, + jitter, + reference_code: refCode, + account: { + bank: account.bank, + account_number: account.account_number, + owner_name: account.owner_name, + owner_rut: account.owner_rut, + }, + expires_at: expiresAt, + }); +}); + +fastify.get('/intent/:id', async (req, reply) => { + const { rows: [intent] } = await query(`SELECT * FROM payment_intents WHERE id = $1`, [req.params.id]); + if (!intent) return reply.code(404).send({ error: 'not found' }); + return reply.send(intent); +}); + +fastify.delete('/intent/:id', async (req, reply) => { + const { rows: [intent] } = await query(`SELECT id, status FROM payment_intents WHERE id = $1`, [req.params.id]); + if (!intent) return reply.code(404).send({ error: 'not found' }); + if (intent.status === 'matched') return reply.code(409).send({ error: 'already matched' }); + await query(`UPDATE payment_intents SET status = 'cancelled' WHERE id = $1`, [intent.id]); + return reply.send({ ok: true }); +}); + +// ─── Routes: admin ──────────────────────────────────────────────────────────── + +// List unaccounted inbound payments +fastify.get('/admin/inbound', async (req) => { + const limit = Math.min(parseInt(req.query.limit ?? '50', 10), 200); + const { rows } = await query( + `SELECT * FROM inbound_payments WHERE status = 'unaccounted' ORDER BY detected_at DESC LIMIT $1`, + [limit] + ); + return { unaccounted: rows, count: rows.length }; +}); + +// Manually trigger a match sweep over all unaccounted inbounds +fastify.post('/admin/match', async (_req, reply) => { + const { rows } = await query(`SELECT * FROM inbound_payments WHERE status = 'unaccounted' ORDER BY detected_at ASC`); + let matched = 0; + for (const inbound of rows) { + const result = await matchInbound(inbound); + if (result) { + await confirmAndDispatch(result.intent, inbound, result.confidence); + matched++; + } + } + return reply.send({ swept: rows.length, matched }); +}); + +// Expire stale intents +fastify.post('/admin/expire', async (_req, reply) => { + const { rowCount } = await query( + `UPDATE payment_intents SET status = 'expired' WHERE status = 'pending' AND expires_at < now()` + ); + return reply.send({ expired: rowCount }); +}); + +// List all accounts +fastify.get('/admin/accounts', async () => { + const { rows } = await query(`SELECT id, bank, account_number, owner_name, owner_rut, active, bci_subscription_id FROM accounts ORDER BY created_at`); + return { accounts: rows }; +}); + +// Register a new account +fastify.post('/admin/accounts', async (req, reply) => { + const { bank, account_number, owner_name, owner_rut, check_digit, api_key, webhook_secret } = req.body ?? {}; + if (!bank || !account_number || !owner_name || !owner_rut) { + return reply.code(400).send({ error: 'bank, account_number, owner_name, owner_rut required' }); + } + const id = newId(); + await query( + `INSERT INTO accounts (id, bank, account_number, owner_name, owner_rut, check_digit, api_key, webhook_secret) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + ON CONFLICT (bank, account_number) DO UPDATE + SET owner_name = EXCLUDED.owner_name, + owner_rut = EXCLUDED.owner_rut, + check_digit = EXCLUDED.check_digit, + api_key = EXCLUDED.api_key, + webhook_secret = EXCLUDED.webhook_secret`, + [id, bank, account_number, owner_name, owner_rut, check_digit ?? null, api_key ?? null, webhook_secret ?? null] + ); + const { rows: [acc] } = await query(`SELECT * FROM accounts WHERE bank = $1 AND account_number = $2`, [bank, account_number]); + return reply.code(201).send(acc); +}); + +// Register BCI webhook subscription for an account +fastify.post('/admin/bci/subscribe', async (req, reply) => { + const { account_id } = req.body ?? {}; + const { rows: [acc] } = await query(`SELECT * FROM accounts WHERE id = $1`, [account_id ?? '']); + if (!acc) return reply.code(404).send({ error: 'account not found' }); + + const callbackUrl = `${PUBLIC_URL}/bci/notify`; + const result = await createSubscription({ + organizationName: acc.owner_name, + account: acc.account_number, + rut: acc.owner_rut, + checkDigit: acc.check_digit, + callbackUrl, + subscriptionKey: acc.api_key ?? BCI_CFG.subscriptionKey, + webhookSecret: acc.webhook_secret ?? BCI_CFG.webhookSecret, + }); + + await query(`UPDATE accounts SET bci_subscription_id = $1 WHERE id = $2`, [JSON.stringify(result), acc.id]); + return reply.send({ ok: true, subscription: result, callback_url: callbackUrl }); +}); + +// Fire a test BCI notification (discovers real payload shape) +fastify.post('/admin/bci/simulate', async (req, reply) => { + const { account_id, amount = 1000 } = req.body ?? {}; + const { rows: [acc] } = await query(`SELECT * FROM accounts WHERE id = $1`, [account_id ?? '']); + if (!acc) return reply.code(404).send({ error: 'account not found' }); + + const result = await simulateNotification({ + callbackUrl: `${PUBLIC_URL}/bci/notify`, + amount, + subscriptionKey: acc.api_key ?? BCI_CFG.subscriptionKey, + webhookSecret: acc.webhook_secret ?? BCI_CFG.webhookSecret, + }); + return reply.send({ ok: true, result }); +}); + +// ─── Matcher helper ─────────────────────────────────────────────────────────── + +async function runMatcher(inbound) { + const result = await matchInbound(inbound); + if (result) { + fastify.log.info({ intent_id: result.intent.id, inbound_id: inbound.id, confidence: result.confidence }, 'match found'); + await confirmAndDispatch(result.intent, inbound, result.confidence); + } +} + +// ─── Periodic sweep ─────────────────────────────────────────────────────────── +// Re-attempt unaccounted inbounds every 5 minutes in case a pending intent +// was registered after the inbound arrived (race condition). + +function startSweepCron() { + setInterval(async () => { + try { + // Expire stale intents first + await query(`UPDATE payment_intents SET status = 'expired' WHERE status = 'pending' AND expires_at < now()`); + + const { rows } = await query(`SELECT * FROM inbound_payments WHERE status = 'unaccounted' ORDER BY detected_at ASC LIMIT 100`); + for (const inbound of rows) { + const result = await matchInbound(inbound); + if (result) await confirmAndDispatch(result.intent, inbound, result.confidence); + } + } catch (err) { + fastify.log.error({ err }, 'sweep cron error'); + } + }, 5 * 60 * 1000); +} + +// ─── Start ──────────────────────────────────────────────────────────────────── + +try { + await migrate(); + fastify.log.info('database migrated'); + await fastify.listen({ port: PORT, host: '0.0.0.0' }); + startSweepCron(); + fastify.log.info(`kua-cashier listening on ${PORT}`); +} catch (err) { + fastify.log.error(err); + process.exit(1); +} diff --git a/util.js b/util.js new file mode 100644 index 0000000..57230d1 --- /dev/null +++ b/util.js @@ -0,0 +1,27 @@ +import { randomBytes, createHmac, randomUUID } from 'crypto'; + +export function newId() { + return randomUUID().replace(/-/g, ''); +} + +export function genRefCode() { + // 6 uppercase alphanumeric chars — customer puts this in transfer description + return randomBytes(4).toString('base64url').slice(0, 6).toUpperCase(); +} + +export function normalizeRut(rut) { + if (!rut) return null; + // Strip dots, lowercase → '12345678-9' or '123456789' + return rut.replace(/\./g, '').toLowerCase(); +} + +export function hmacSign(secret, payload) { + return createHmac('sha256', secret).update(payload).digest('hex'); +} + +// Parse CLP amount from BCI (arrives as string like "1500" or "1500.00") +export function parsePesos(raw) { + if (raw == null) return null; + const n = Math.round(parseFloat(String(raw))); + return isNaN(n) ? null : n; +}