feat: initial commit — kua-cashier payment confirmation engine
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.
This commit is contained in:
commit
c505ae4d50
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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 }; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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]
|
||||
);
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue