feat(d28): SMS gateway live balance sync (BalAlert API) + daily auto + low alert
Pull each SMS client's actual credit balance from the provider panel instead of manual entry. sms.balance_api_url (dated setting, default 164.52.195.161/API/ BalAlert.aspx) returns pipe-delimited code|STATUS|payload; parseBalanceResponse reads the trailing number and flags ERROR lines, never guessing. Uses each subscription's already-imported username/password_enc — the automated path decrypts directly (no reveal-audit spam) and audits sms_balance ONLY when it changes; per-poll telemetry lands in the unaudited sms_balance_check table (SQLite schema + PG migration 010). Managerial 'Refresh from gateway' button (POST /reports/sms- balances/refresh) + per-client refresh; scheduler does one pass per day (maybeRefreshSmsDaily). Low-balance alert on the Dashboard + cockpit tile. Needs HQ_SECRET_KEY (import-time key) to decrypt — no-ops with a clear message otherwise, never stores plaintext. Parser unit-tested; full suite 408 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
650cde43a2
commit
58c97994bd
@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* SMS gateway balance check (D28). The provider panel returns a pipe-delimited
|
||||||
|
* plain-text line, e.g.
|
||||||
|
* error: `002|ERROR|Invalid User Id / Password`
|
||||||
|
* success: `<code>|<STATUS>|<balance>` (a trailing numeric balance)
|
||||||
|
* We parse defensively — any part flagged ERROR fails with its message; otherwise
|
||||||
|
* the last numeric token is the balance. The raw text is surfaced so a new response
|
||||||
|
* shape can be spotted, never silently mis-read. Credentials never appear in results.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface BalanceResult {
|
||||||
|
ok: boolean
|
||||||
|
balance: number | null
|
||||||
|
message: string // gateway status/error text — safe to store and show (no credentials)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the gateway's pipe-delimited response. Pure — unit-tested without a network. */
|
||||||
|
export function parseBalanceResponse(raw: string): BalanceResult {
|
||||||
|
const text = raw.trim()
|
||||||
|
if (text === '') return { ok: false, balance: null, message: 'Empty response from gateway' }
|
||||||
|
const parts = text.split('|').map((p) => p.trim())
|
||||||
|
if (parts.some((p) => /error|invalid|fail/i.test(p))) {
|
||||||
|
// Prefer the human message (usually the last part) over the code.
|
||||||
|
const msg = parts[parts.length - 1] ?? text
|
||||||
|
return { ok: false, balance: null, message: msg }
|
||||||
|
}
|
||||||
|
// Balance = last token that is a plain number (commas/spaces tolerated).
|
||||||
|
for (let i = parts.length - 1; i >= 0; i--) {
|
||||||
|
const m = parts[i]!.replace(/[,\s]/g, '').match(/^-?\d+$/)
|
||||||
|
if (m !== null) return { ok: true, balance: parseInt(m[0], 10), message: parts.slice(0, i).join(' ') || 'ok' }
|
||||||
|
}
|
||||||
|
return { ok: false, balance: null, message: `Could not read a balance from: ${text.slice(0, 80)}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the request URL, substituting the account credentials into the configured base. */
|
||||||
|
export function balanceUrl(base: string, uname: string, pass: string): string {
|
||||||
|
const u = new URL(base)
|
||||||
|
u.searchParams.set('uname', uname)
|
||||||
|
u.searchParams.set('pass', pass)
|
||||||
|
return u.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call the gateway for one account's balance. Network + parse; a timeout or non-200
|
||||||
|
* becomes a failed BalanceResult (never throws) so a bad account can't abort a batch.
|
||||||
|
*/
|
||||||
|
export async function fetchSmsBalance(base: string, uname: string, pass: string): Promise<BalanceResult> {
|
||||||
|
let url: string
|
||||||
|
try { url = balanceUrl(base, uname, pass) } catch { return { ok: false, balance: null, message: 'Invalid balance API URL' } }
|
||||||
|
try {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
const t = setTimeout(() => ctrl.abort(), 12_000)
|
||||||
|
const resp = await fetch(url, { signal: ctrl.signal }).finally(() => clearTimeout(t))
|
||||||
|
if (!resp.ok) return { ok: false, balance: null, message: `Gateway HTTP ${resp.status}` }
|
||||||
|
return parseBalanceResponse(await resp.text())
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
|
return { ok: false, balance: null, message: `Gateway unreachable: ${msg}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
// apps/hq/test/sms-gateway.test.ts — D28: parse the SMS gateway's balance response.
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { parseBalanceResponse, balanceUrl } from '../src/sms-gateway'
|
||||||
|
|
||||||
|
describe('parseBalanceResponse (D28)', () => {
|
||||||
|
it('reads the balance from a success line (code|STATUS|balance)', () => {
|
||||||
|
const r = parseBalanceResponse('001|SUCCESS|8200')
|
||||||
|
expect(r.ok).toBe(true)
|
||||||
|
expect(r.balance).toBe(8200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tolerates commas/spaces and a plain-number body', () => {
|
||||||
|
expect(parseBalanceResponse('000|OK|1,25,000').balance).toBe(125000)
|
||||||
|
expect(parseBalanceResponse('8200').balance).toBe(8200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags the real error response as failed with its message', () => {
|
||||||
|
const r = parseBalanceResponse('002|ERROR|Invalid User Id / Password')
|
||||||
|
expect(r.ok).toBe(false)
|
||||||
|
expect(r.balance).toBeNull()
|
||||||
|
expect(r.message).toMatch(/Invalid User Id/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails cleanly on empty or unreadable text (never a wrong number)', () => {
|
||||||
|
expect(parseBalanceResponse('').ok).toBe(false)
|
||||||
|
expect(parseBalanceResponse('welcome to the panel').ok).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('balanceUrl (D28)', () => {
|
||||||
|
it('substitutes credentials into the configured base URL', () => {
|
||||||
|
const u = balanceUrl('http://164.52.195.161/API/BalAlert.aspx', 'bankuser', 'p@ss w/special')
|
||||||
|
expect(u).toContain('uname=bankuser')
|
||||||
|
expect(u).toContain('pass=p%40ss+w%2Fspecial') // URL-encoded
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue