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
Thomas Joise 3 days ago
parent 650cde43a2
commit 58c97994bd

@ -925,6 +925,8 @@ export interface SmsBalanceRow {
cmId: string; clientId: string; clientName: string; clientCode: string
balanceText: string | null; balance: number | null; low: boolean
reseller: string | null; status: string
provider: string | null; hasCreds: boolean
checkStatus: string | null; checkMessage: string | null; checkedAt: string | null
}
export interface SmsBalances {
rows: SmsBalanceRow[]; threshold: number
@ -933,6 +935,16 @@ export interface SmsBalances {
export const getSmsBalances = (): Promise<SmsBalances> =>
apiFetch<SmsBalances>('/reports/sms-balances')
// D28: live balance fetch from the gateway (managerial — decrypts credentials).
export interface SmsRefreshSummary {
attempted: number; ok: number; failed: number; changed: number; skipped: number
}
export const refreshSmsBalances = (): Promise<SmsRefreshSummary> =>
apiFetch<{ summary: SmsRefreshSummary }>('/reports/sms-balances/refresh', { method: 'POST', body: '{}' }).then((r) => r.summary)
export const refreshOneSmsBalance = (cmId: string): Promise<{ ok: boolean; balance: number | null; message: string }> =>
apiFetch<{ result: { ok: boolean; balance: number | null; message: string } }>(
`/client-modules/${cmId}/refresh-sms-balance`, { method: 'POST', body: '{}' }).then((r) => r.result)
// D27: audit log viewer (owner-only).
export interface AuditRow {
id: string; at_wall: string; user_id: string; action: string

@ -1,14 +1,31 @@
import { useState, type ReactNode } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import { Badge, DataTable, EmptyState, ErrorState, Notice, PageHeader, Skeleton, StatCard, Stats } from '@sims/ui'
import { getDashboard, isManagerial } from '../api'
import { Badge, DataTable, EmptyState, ErrorState, Notice, Skeleton, StatCard, Stats } from '@sims/ui'
import { displayName, getDashboard, getSmsBalances, isManagerial } from '../api'
import { QueueActions, RULE_LABEL, reminderTone } from '../components/ReminderQueue'
import { useData } from './Clients'
const inr = (p: number) => formatINR(p)
/** Dashboard home — today's money + the manual reminder queue (spec §3). */
/** 'YYYY-MM-DD' → 'Sunday 19 July 2026' (falls back to the raw string if unparseable). */
const longDate = (iso: string): string => {
const d = new Date(`${iso}T00:00:00`)
return Number.isNaN(d.getTime())
? iso
: d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
}
/** 'YYYY-MM-DD' → '19 Jul' (falls back to the raw string if unparseable). */
const shortDate = (iso: string): string => {
const d = new Date(`${iso}T00:00:00`)
return Number.isNaN(d.getTime()) ? iso : d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
}
/**
* Dashboard home the "cockpit" (D29): a Today hero strip, the four KPI stat
* cards, the reminder queue, then a bento grid of the day's sections. Low-colour
* on purpose ink + neutrals + one teal accent, red only for real failures.
*/
export function Dashboard() {
const nav = useNavigate()
// My Day (D18 WS-E): owner/manager toggle Mine ↔ Everyone; staff are always
@ -16,27 +33,52 @@ export function Dashboard() {
const managerial = isManagerial()
const [mine, setMine] = useState(false)
const dash = useData(() => getDashboard(mine), [mine])
const smsLow = useData(getSmsBalances, []) // D28: low-balance alert, seen even without opening the SMS screen
const [err, setErr] = useState('')
const v = dash.data
const name = displayName()
return (
<div className="wf-page">
<PageHeader
title={managerial && mine ? 'My Day' : 'Dashboard'}
desc="Todays money — dues, renewals, follow-ups, and the reminder queue."
{...(managerial ? {
actions: (
<span style={{ display: 'flex', gap: 4 }}>
<button type="button" className={`wf${!mine ? ' active' : ''}`} onClick={() => setMine(false)}>Everyone</button>
<button type="button" className={`wf${mine ? ' active' : ''}`} onClick={() => setMine(true)}>Mine</button>
</span>
),
} : {})}
/>
{dash.error !== undefined && <ErrorState message={dash.error} onRetry={dash.reload} />}
{err !== '' && <Notice tone="err">{err}</Notice>}
{v === undefined && dash.error === undefined ? <Skeleton rows={5} /> : v === undefined ? null : (
{v === undefined && dash.error === undefined ? <Skeleton rows={6} /> : v === undefined ? null : (
<>
{/* Today hero — date, greeting, the headline overdue figure, scope + quick actions. */}
<section className="dash-hero">
<div className="dash-hero-l">
<div className="dash-hero-eyebrow">{longDate(v.today)}</div>
<h1>Welcome back{name !== '' ? `, ${name}` : ''}</h1>
<p>Heres what needs you today dues to chase, renewals landing, and the reminder queue.</p>
{managerial && (
<div className="dash-seg" role="group" aria-label="Scope">
<button type="button" className={!mine ? 'on' : ''} onClick={() => setMine(false)}>Everyone</button>
<button type="button" className={mine ? 'on' : ''} onClick={() => setMine(true)}>Mine</button>
</div>
)}
</div>
<div className="dash-hero-r">
<div className="dash-money">
<div className="k">Overdue</div>
<div className="v num">{inr(v.totals.overduePaise)}</div>
</div>
<div className="dash-hero-actions">
<button type="button" className="wf primary" onClick={() => nav('/documents/new')}>New document</button>
<button type="button" className="wf" onClick={() => nav('/clients?new=1')}>New client</button>
</div>
</div>
</section>
{/* SMS low-balance alert (D28) — surfaced here so it's seen without opening the SMS screen. */}
{smsLow.data !== undefined && smsLow.data.lowCount > 0 && (
<Notice tone="warn">
<Link to="/sms-balances" style={{ color: 'inherit', fontWeight: 600 }}>
{smsLow.data.lowCount} SMS client{smsLow.data.lowCount === 1 ? '' : 's'} low on balance review &amp; top up
</Link>
</Notice>
)}
{/* KPIs — the app's existing stat cards (kept minimal; red only when a send failed). */}
<Stats>
<StatCard label="Overdue" value={inr(v.totals.overduePaise)} hint={`${v.overdue.length} invoice(s)`} />
<StatCard label="Queued reminders" value={String(v.totals.queued)} />
@ -44,69 +86,78 @@ export function Dashboard() {
<StatCard label="Follow-ups today" value={String(v.followUpsToday.length)} />
</Stats>
<h3 style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
Reminder queue
<Link to="/reminders" style={{ fontSize: 13, fontWeight: 500 }}>View all </Link>
</h3>
{v.queue.length === 0 ? <EmptyState>Nothing waiting all clear.</EmptyState> : (
<DataTable
columns={[
{ key: 'kind', label: 'Kind' }, { key: 'client', label: 'Client' },
{ key: 'ref', label: 'Reference' }, { key: 'status', label: 'Status' },
{ key: 'error', label: 'Error' }, { key: 'act', label: '' },
]}
rowTone={(i) => {
const s = v.queue[i]?.status
return s === 'failed' ? 'err' : undefined
}}
rows={v.queue.map((rem) => ({
kind: rem.label !== '' ? rem.label : RULE_LABEL[rem.ruleKind] ?? rem.ruleKind,
client: rem.clientName,
ref: rem.docNo ?? rem.duePeriod,
status: <Badge tone={reminderTone(rem.status)}>{rem.status}</Badge>,
error: rem.error ?? '—',
act: <QueueActions rem={rem} onDone={dash.reload} onError={setErr} />,
}))}
/>
)}
{v.queueTotal > v.queue.length && (
// No silent caps (rule 8): the table is the first page only — say so.
<p style={{ marginTop: 6, fontSize: 13, color: 'var(--text-muted, #666)' }}>
Showing {v.queue.length} of {v.queueTotal} <Link to="/reminders">see the full queue</Link>.
</p>
)}
{/* Reminder queue — full send/preview/dismiss actions preserved. */}
<section className="dash-queue">
<div className="dash-head">
<h3>Reminder queue</h3>
<span className="count">{v.queueTotal}</span>
<Link to="/reminders">View all </Link>
</div>
{v.queue.length === 0 ? <EmptyState>Nothing waiting all clear.</EmptyState> : (
<DataTable
columns={[
{ key: 'kind', label: 'Kind' }, { key: 'client', label: 'Client' },
{ key: 'ref', label: 'Reference' }, { key: 'status', label: 'Status' },
{ key: 'error', label: 'Error' }, { key: 'act', label: '' },
]}
rowTone={(i) => (v.queue[i]?.status === 'failed' ? 'err' : undefined)}
rows={v.queue.map((rem) => ({
kind: rem.label !== '' ? rem.label : RULE_LABEL[rem.ruleKind] ?? rem.ruleKind,
client: rem.clientName,
ref: rem.docNo ?? rem.duePeriod,
status: <Badge tone={reminderTone(rem.status)}>{rem.status}</Badge>,
error: rem.error ?? '—',
act: <QueueActions rem={rem} onDone={dash.reload} onError={setErr} />,
}))}
/>
)}
{v.queueTotal > v.queue.length && (
// No silent caps (rule 8): the table is the first page only — say so.
<p className="dash-cap">
Showing {v.queue.length} of {v.queueTotal} <Link to="/reminders">see the full queue</Link>.
</p>
)}
</section>
<Section title="Overdue" empty="No overdue invoices." rows={v.overdue}
columns={[{ key: 'no', label: 'Invoice' }, { key: 'client', label: 'Client' }, { key: 'days', label: 'Days', numeric: true }, { key: 'due', label: 'Outstanding', numeric: true }]}
onRowClick={(i) => nav(`/documents/${v.overdue[i]!.docId}`)}
map={(o) => ({ no: o.docNo ?? '—', client: o.clientName, days: o.daysOverdue, due: inr(o.outstandingPaise) })} />
{/* Bento — the day's read-only sections. */}
<div className="dash-bento">
<Card title="Overdue invoices" count={v.overdue.length} empty="No overdue invoices." isEmpty={v.overdue.length === 0}>
{v.overdue.map((o) => (
<Row key={o.docId} onClick={() => nav(`/documents/${o.docId}`)}
title={<span className="mono">{o.docNo ?? '—'}</span>} sub={o.clientName}
right={inr(o.outstandingPaise)} rightSub={`${o.daysOverdue}d overdue`} />
))}
</Card>
<div className="dash-grid">
<section>
<Section title="Due this week" empty="No recurring bills due this week." rows={v.dueThisWeek}
columns={[{ key: 'client', label: 'Client' }, { key: 'on', label: 'Runs on' }, { key: 'amt', label: 'Amount', numeric: true }]}
map={(d) => ({ client: d.clientName, on: d.nextRun, amt: d.amountPaise !== null ? inr(d.amountPaise) : 'from price book' })} />
</section>
<Card title="Due this week" count={v.dueThisWeek.length} empty="No recurring bills due this week." isEmpty={v.dueThisWeek.length === 0}>
{v.dueThisWeek.map((d) => (
<Row key={d.planId} onClick={() => nav(`/clients/${d.clientId}`)}
title={d.clientName} sub={`runs ${shortDate(d.nextRun)}`}
{...(d.amountPaise !== null ? { right: inr(d.amountPaise) } : { rightSub: 'from price book' })} />
))}
</Card>
<section>
<Section title="Renewals this month" empty="No renewals this month." rows={v.renewalsThisMonth}
columns={[{ key: 'client', label: 'Client' }, { key: 'on', label: 'Renews on' }]}
onRowClick={(i) => nav(`/clients/${v.renewalsThisMonth[i]!.clientId}`)}
map={(r) => ({ client: r.clientName, on: r.nextRenewal })} />
</section>
<Card title="Renewals this month" count={v.renewalsThisMonth.length} link={{ to: '/renewals' }} empty="No renewals this month." isEmpty={v.renewalsThisMonth.length === 0}>
{v.renewalsThisMonth.map((r) => (
<Row key={r.clientModuleId} onClick={() => nav(`/clients/${r.clientId}`)}
title={r.clientName} rightSub={shortDate(r.nextRenewal)} />
))}
</Card>
<section>
<Section title="Follow-ups today" empty="No follow-ups due." rows={v.followUpsToday}
columns={[{ key: 'client', label: 'Client' }, { key: 'on', label: 'Due' }, { key: 'notes', label: 'Notes' }]}
onRowClick={(i) => nav(`/clients/${v.followUpsToday[i]!.clientId}`)}
map={(f) => ({ client: f.clientName, on: f.followUpOn, notes: f.notes !== '' ? f.notes : '—' })} />
</section>
<Card title="Follow-ups today" count={v.followUpsToday.length} empty="No follow-ups due." isEmpty={v.followUpsToday.length === 0}>
{v.followUpsToday.map((f) => (
<Row key={f.id} onClick={() => nav(`/clients/${f.clientId}`)}
title={f.clientName} sub={f.notes !== '' ? f.notes : '—'} />
))}
</Card>
<section>
<Section title="Recent payments" empty="No payments recorded yet." rows={v.recentPayments}
columns={[{ key: 'client', label: 'Client' }, { key: 'on', label: 'Received' }, { key: 'mode', label: 'Mode' }, { key: 'amt', label: 'Amount', numeric: true }]}
map={(p) => ({ client: p.clientName, on: p.receivedOn, mode: p.mode, amt: inr(p.amountPaise) })} />
</section>
<Card title="Recent payments" count={v.recentPayments.length} empty="No payments recorded yet." isEmpty={v.recentPayments.length === 0}>
{v.recentPayments.map((p) => (
<Row key={p.id} onClick={() => nav(`/clients/${p.clientId}`)}
title={p.clientName} sub={`${shortDate(p.receivedOn)} · ${p.mode}`}
right={inr(p.amountPaise)} />
))}
</Card>
</div>
</>
)}
@ -114,23 +165,44 @@ export function Dashboard() {
)
}
/** A titled table with an empty state — keeps the dashboard body declarative. */
function Section<T>(props: {
title: string; empty: string; rows: T[]
columns: { key: string; label: string; numeric?: boolean }[]
map: (row: T) => Record<string, ReactNode>; onRowClick?: (i: number) => void
/** One bento card: an uppercase-labelled header (+ count and optional link) over a
* scrollable list, or an empty-state line when there's nothing to show. */
function Card(props: {
title: string; count?: number; link?: { to: string; label?: string }
empty: string; isEmpty: boolean; children: ReactNode
}) {
return (
<section className="dash-card">
<div className="dash-head">
<h3>{props.title}</h3>
{props.count !== undefined && <span className="count">{props.count}</span>}
{props.link !== undefined && <Link to={props.link.to}>{props.link.label ?? 'All'} </Link>}
</div>
{props.isEmpty ? <div className="dash-empty">{props.empty}</div> : <div className="dash-list">{props.children}</div>}
</section>
)
}
/** One list row a title/sub on the left, an optional amount/sub on the right.
* Given `onClick`, it renders as a keyboard-operable <button>. */
function Row(props: {
title: ReactNode; sub?: ReactNode; right?: ReactNode; rightSub?: ReactNode; onClick?: () => void
}) {
const body = (
<>
<h3 style={{ marginTop: 20 }}>{props.title}</h3>
{props.rows.length === 0 ? <EmptyState>{props.empty}</EmptyState> : (
<DataTable
columns={props.columns}
{...(props.onRowClick !== undefined ? { onRowClick: (_r: unknown, i: number) => props.onRowClick!(i) } : {})}
rows={props.rows.map(props.map)}
/>
<span className="rc">
<span className="t">{props.title}</span>
{props.sub !== undefined && <span className="s">{props.sub}</span>}
</span>
{(props.right !== undefined || props.rightSub !== undefined) && (
<span className="r">
{props.right !== undefined && <span className="amt">{props.right}</span>}
{props.rightSub !== undefined && <span className="sub">{props.rightSub}</span>}
</span>
)}
</>
)
return props.onClick !== undefined
? <button type="button" className="dash-row" onClick={props.onClick}>{body}</button>
: <div className="dash-row">{body}</div>
}

@ -3,11 +3,26 @@ import { Link, useNavigate } from 'react-router-dom'
import {
Badge, Button, DataTable, EmptyState, ErrorState, FilterChips, Notice, PageHeader, Skeleton, Toolbar, useToast,
} from '@sims/ui'
import { getSmsBalances, generateModuleRenewalQuote, type SmsBalanceRow } from '../api'
import {
getSmsBalances, generateModuleRenewalQuote, refreshSmsBalances, isManagerial, type SmsBalanceRow,
} from '../api'
import { useData } from './Clients'
type Scope = 'low' | 'all' | 'unknown'
/** "3h ago" / "2d ago" from an ISO timestamp — compact freshness for the Checked column. */
function ago(iso: string | null): string {
if (iso === null) return 'never'
const ms = Date.now() - Date.parse(iso)
if (Number.isNaN(ms)) return '—'
const mins = Math.floor(ms / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
return `${Math.floor(hrs / 24)}d ago`
}
/**
* SMS credit review (D28): every SMS client with its balance, lowest first, so the
* ones about to run dry are at the top. "Low" (below the threshold) is the default
@ -20,8 +35,21 @@ export function SmsBalances() {
const toast = useToast()
const [scope, setScope] = useState<Scope>('low')
const [busy, setBusy] = useState<string | undefined>()
const [refreshing, setRefreshing] = useState(false)
const list = useData(getSmsBalances, [])
const refreshAll = () => {
if (refreshing) return
setRefreshing(true)
refreshSmsBalances()
.then((s) => {
toast.ok(`Checked ${s.attempted}: ${s.ok} ok, ${s.changed} updated${s.failed > 0 ? `, ${s.failed} failed` : ''}${s.skipped > 0 ? `, ${s.skipped} no login` : ''}`)
list.reload()
})
.catch((e: Error) => toast.err(e.message))
.finally(() => setRefreshing(false))
}
const raiseTopup = (r: SmsBalanceRow) => {
if (busy !== undefined) return
setBusy(r.cmId)
@ -43,6 +71,9 @@ export function SmsBalances() {
<PageHeader
title="SMS balances"
desc="SMS credits across every client, lowest first — spot who is about to run dry and raise a top-up before they do."
actions={isManagerial()
? <Button tone="primary" disabled={refreshing} onClick={refreshAll}>{refreshing ? 'Checking…' : 'Refresh from gateway'}</Button>
: undefined}
/>
{data !== undefined && data.lowCount > 0 && scope !== 'low' && (
<Notice tone="warn">
@ -75,7 +106,8 @@ export function SmsBalances() {
columns={[
{ key: 'client', label: 'Client' },
{ key: 'balance', label: 'Balance', numeric: true },
{ key: 'reseller', label: 'Reseller' },
{ key: 'provider', label: 'Provider' },
{ key: 'checked', label: 'Checked' },
{ key: 'actions', label: '' },
]}
rowTone={(i) => (shown[i]!.low ? 'err' : undefined)}
@ -88,7 +120,12 @@ export function SmsBalances() {
{r.balance.toLocaleString('en-IN')}
</Badge>
),
reseller: r.reseller ?? '—',
provider: r.provider ?? '—',
checked: r.checkStatus === 'error'
? <span title={r.checkMessage ?? ''} style={{ color: 'var(--err)', fontSize: 12.5 }}> {ago(r.checkedAt)}</span>
: !r.hasCreds
? <span style={{ opacity: 0.5, fontSize: 12.5 }}>no login</span>
: <span style={{ opacity: 0.7, fontSize: 12.5 }}>{ago(r.checkedAt)}</span>,
actions: (
<span onClick={(e) => e.stopPropagation()}>
<Button tone="primary" disabled={busy !== undefined} onClick={() => raiseTopup(r)}>

@ -62,7 +62,7 @@ import { clientProfitability, duesAging, gstSummary, moduleRevenue, type DateRan
import { listUsageRateCards, setUsageRateCard } from './repos-rate-card'
import { renewalsDue, generateModuleRenewalQuote } from './repos-renewals'
import { misCockpit } from './repos-mis'
import { smsBalances } from './repos-sms'
import { smsBalances, refreshSmsBalance, refreshAllSmsBalances } from './repos-sms'
import { makeRateLimiter } from './rate-limit'
import {
addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard,
@ -1529,6 +1529,26 @@ export function apiRouter(
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// Poll every SMS account's live balance from the gateway (decrypts credentials → managerial).
r.post('/reports/sms-balances/refresh', requireAuth, async (_req, res) => {
const viewer = res.locals['staff'] as { id: string; role: string }
if (!isManagerial(viewer.role)) { res.status(403).json({ ok: false, error: 'Owner or manager only' }); return }
try {
res.json({ ok: true, summary: await refreshAllSmsBalances(db, viewer.id, gmail().keyHex) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// Poll one client's SMS balance (Client 360 / per-row refresh).
r.post('/client-modules/:id/refresh-sms-balance', requireAuth, async (req, res) => {
const viewer = res.locals['staff'] as { id: string; role: string }
if (!isManagerial(viewer.role)) { res.status(403).json({ ok: false, error: 'Owner or manager only' }); return }
try {
res.json({ ok: true, result: await refreshSmsBalance(db, viewer.id, String(req.params['id'] ?? ''), gmail().keyHex) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- audit log viewer (D27, owner-only) ----------
r.get('/audit', requireAuth, requireOwner, async (req, res) => {

@ -239,6 +239,16 @@ CREATE TABLE IF NOT EXISTS ticket (
created_by TEXT NOT NULL, created_at TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'hq' -- 'apex' rows are the imported history
);
-- D28: SMS gateway balance-check telemetry (one row per SMS subscription). Operational
-- sync log, NOT audited domain data the authoritative balance lives in client_module
-- field_values.sms_balance (that change IS audited). This just records the last poll.
CREATE TABLE IF NOT EXISTS sms_balance_check (
client_module_id TEXT PRIMARY KEY,
status TEXT NOT NULL, -- 'ok' | 'error'
balance INTEGER, -- parsed count on success, NULL on error
message TEXT, -- gateway status/error text (never a credential)
checked_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tax_class (
class_code TEXT NOT NULL, rate_pct_bp INTEGER NOT NULL,
cess_pct_bp INTEGER NOT NULL DEFAULT 0, effective_from TEXT NOT NULL, effective_to TEXT

@ -292,6 +292,19 @@ CREATE INDEX IF NOT EXISTS ix_milestone_cm ON project_milestone (client_module_i
ALTER TABLE staff_user ADD COLUMN IF NOT EXISTS username text;
UPDATE staff_user SET username=email WHERE username IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS ix_staff_username ON staff_user (username);
`,
},
{
// D28: SMS gateway balance-check telemetry (operational sync log, not audited).
id: '010-sms-balance-check',
sql: `
CREATE TABLE IF NOT EXISTS sms_balance_check (
client_module_id text PRIMARY KEY,
status text NOT NULL,
balance integer,
message text,
checked_at text NOT NULL
);
`,
},
]

@ -1,5 +1,11 @@
import type { DB } from './db'
import { getNumberSetting } from './repos-reminders'
import { getNumberSetting, getSetting, setSetting } from './repos-reminders'
import { decrypt } from './crypto'
import { writeAudit } from './audit'
import { fetchSmsBalance } from './sms-gateway'
/** Default gateway panel (config over code — override with the sms.balance_api_url setting). */
export const DEFAULT_SMS_BALANCE_API = 'http://164.52.195.161/API/BalAlert.aspx'
/**
* SMS credit review (D28) the "who is running low, who to top up" screen and its
@ -12,6 +18,9 @@ export interface SmsBalanceRow {
cmId: string; clientId: string; clientName: string; clientCode: string
balanceText: string | null; balance: number | null; low: boolean
reseller: string | null; status: string
provider: string | null; hasCreds: boolean
/** Last gateway poll (D28): 'ok' | 'error' | null (never polled). */
checkStatus: string | null; checkMessage: string | null; checkedAt: string | null
}
export interface SmsBalances {
rows: SmsBalanceRow[]; threshold: number
@ -37,11 +46,15 @@ export async function smsBalances(db: DB): Promise<SmsBalances> {
if (mod === undefined) return { rows: [], threshold, total: 0, lowCount: 0, unknownCount: 0 }
const raw = await db.all<{
cm_id: string; client_id: string; client_name: string; client_code: string
field_values: string; status: string
field_values: string; status: string; provider: string | null; username: string | null
check_status: string | null; check_message: string | null; checked_at: string | null
}>(
`SELECT cm.id AS cm_id, cm.client_id, c.name AS client_name, c.code AS client_code,
cm.field_values, cm.status
FROM client_module cm JOIN client c ON c.id = cm.client_id
cm.field_values, cm.status, cm.provider, cm.username,
s.status AS check_status, s.message AS check_message, s.checked_at
FROM client_module cm
JOIN client c ON c.id = cm.client_id
LEFT JOIN sms_balance_check s ON s.client_module_id = cm.id
WHERE cm.module_id = ? AND cm.active = 1`,
mod.id,
)
@ -55,6 +68,9 @@ export async function smsBalances(db: DB): Promise<SmsBalances> {
balanceText, balance, low: balance !== null && balance < threshold,
reseller: typeof fv.reseller === 'string' && fv.reseller.trim() !== '' ? fv.reseller.trim() : null,
status: r.status,
provider: r.provider !== null && r.provider !== '' ? r.provider : null,
hasCreds: r.username !== null && r.username !== '',
checkStatus: r.check_status, checkMessage: r.check_message, checkedAt: r.checked_at,
}
})
// Lowest known balance first (the ones to act on); unknown balances sink to the bottom.
@ -70,3 +86,110 @@ export async function smsBalances(db: DB): Promise<SmsBalances> {
unknownCount: rows.filter((r) => r.balance === null).length,
}
}
// ---------- D28: live balance fetch from the SMS gateway ----------
export interface RefreshResult {
cmId: string; ok: boolean; balance: number | null; changed: boolean; message: string
}
export interface RefreshSummary {
attempted: number; ok: number; failed: number; changed: number; skipped: number
results: RefreshResult[]
}
/** Configured gateway URL, falling back to the built-in default (config over code). */
export async function smsBalanceApiUrl(db: DB): Promise<string> {
const s = await getSetting(db, 'sms.balance_api_url')
return s !== null && s.trim() !== '' ? s.trim() : DEFAULT_SMS_BALANCE_API
}
/** Record one poll's telemetry (unaudited operational log — upsert, portable ON CONFLICT). */
async function recordCheck(db: DB, cmId: string, status: string, balance: number | null, message: string, now: string): Promise<void> {
await db.run(
`INSERT INTO sms_balance_check (client_module_id, status, balance, message, checked_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (client_module_id)
DO UPDATE SET status=excluded.status, balance=excluded.balance, message=excluded.message, checked_at=excluded.checked_at`,
cmId, status, balance, message.slice(0, 300), now,
)
}
/**
* Poll one SMS subscription's balance from the gateway and store it. Decrypts the
* account password directly (no reveal-audit this is automated, not a human peek);
* writes the parsed count into field_values.sms_balance and AUDITS only when it
* actually changes. Telemetry (ok/error + time) always lands in sms_balance_check.
* Never throws for a single bad account returns a RefreshResult.
*/
export async function refreshSmsBalance(db: DB, userId: string, cmId: string, keyHex: string, apiUrl?: string): Promise<RefreshResult> {
const row = await db.get<{ username: string | null; password_enc: string | null; field_values: string }>(
`SELECT username, password_enc, field_values FROM client_module WHERE id=?`, cmId,
)
if (row === undefined) return { cmId, ok: false, balance: null, changed: false, message: 'Subscription not found' }
if (row.username === null || row.username === '' || row.password_enc === null) {
const now = new Date().toISOString()
await recordCheck(db, cmId, 'error', null, 'No gateway credentials stored', now)
return { cmId, ok: false, balance: null, changed: false, message: 'No gateway credentials stored' }
}
if (keyHex === '') return { cmId, ok: false, balance: null, changed: false, message: 'HQ_SECRET_KEY not configured — cannot decrypt credentials' }
let pass: string
try { pass = decrypt(row.password_enc, keyHex) } catch { return { cmId, ok: false, balance: null, changed: false, message: 'Could not decrypt stored password' } }
const url = apiUrl ?? await smsBalanceApiUrl(db)
const res = await fetchSmsBalance(url, row.username, pass)
const now = new Date().toISOString()
if (!res.ok || res.balance === null) {
await recordCheck(db, cmId, 'error', null, res.message, now)
return { cmId, ok: false, balance: null, changed: false, message: res.message }
}
// Success: telemetry + write the balance into the domain field, auditing only real changes.
let changed = false
await db.transaction(async () => {
await recordCheck(db, cmId, 'ok', res.balance, res.message, now)
let fv: Record<string, unknown> = {}
try { fv = JSON.parse(row.field_values) as Record<string, unknown> } catch { fv = {} }
const prev = typeof fv.sms_balance === 'string' ? fv.sms_balance : null
const next = String(res.balance)
if (prev !== next) {
changed = true
const merged = { ...fv, sms_balance: next }
await db.run(`UPDATE client_module SET field_values=? WHERE id=?`, JSON.stringify(merged), cmId)
await writeAudit(db, userId, 'sms_balance_sync', 'client_module', cmId, { sms_balance: prev }, { sms_balance: next })
}
})
return { cmId, ok: true, balance: res.balance, changed, message: res.message }
}
/** Poll every active SMS subscription that has stored credentials. Sequential (kind to
* the gateway); one bad account never aborts the batch. Returns an honest summary. */
export async function refreshAllSmsBalances(db: DB, userId: string, keyHex: string): Promise<RefreshSummary> {
const mod = await db.get<{ id: string }>(`SELECT id FROM module WHERE code='SMS'`)
const summary: RefreshSummary = { attempted: 0, ok: 0, failed: 0, changed: 0, skipped: 0, results: [] }
if (mod === undefined) return summary
const url = await smsBalanceApiUrl(db)
const rows = await db.all<{ id: string; username: string | null }>(
`SELECT id, username FROM client_module WHERE module_id=? AND active=1`, mod.id,
)
for (const r of rows) {
if (r.username === null || r.username === '') { summary.skipped++; continue }
summary.attempted++
const res = await refreshSmsBalance(db, userId, r.id, keyHex, url)
summary.results.push(res)
if (res.ok) { summary.ok++; if (res.changed) summary.changed++ } else summary.failed++
}
return summary
}
/**
* Once-a-day gateway sync for the scheduler (D28). Claims the day via a setting so the
* 6-hourly ticks don't re-poll, and no-ops when the decrypt key is absent (nothing to
* decrypt with). Runs at most one full pass per calendar day.
*/
export async function maybeRefreshSmsDaily(db: DB, userId: string, keyHex: string, today: string): Promise<RefreshSummary | null> {
if (keyHex === '') return null
if (await getSetting(db, 'sms.balance_last_refresh') === today) return null
await setSetting(db, userId, 'sms.balance_last_refresh', today) // claim the day up-front (idempotent per day)
return refreshAllSmsBalances(db, userId, keyHex)
}

@ -5,6 +5,7 @@ import { getClientModule } from './repos-modules'
import { outstandingPaise } from './repos-payments'
import { CADENCE_KIND, getRecurringPlan } from './repos-recurring'
import { getNumberSetting, getSetting, resolveSchedule, setReminderStatus, upsertReminder } from './repos-reminders'
import { maybeRefreshSmsDaily } from './repos-sms'
import { sendReminder, type SendReminderDeps } from './send-reminder'
/**
@ -225,6 +226,19 @@ export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promi
}
}
// --- sms balance daily sync (D28) — pull each SMS account's live balance once a day ---
try {
const sms = await maybeRefreshSmsDaily(db, 'system', deps.gmail.keyHex, today)
if (sms !== null) {
created['sms_balance_synced'] = sms.ok
if (sms.changed > 0) created['sms_balance_updated'] = sms.changed
if (sms.failed > 0) created['sms_balance_failed'] = sms.failed
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('[scheduler] sms balance sync failed:', err instanceof Error ? err.message : err)
}
// --- recurring_generated (transactional generation, then async auto-send) ---
const autoQueue = [...followupAuto, ...await generateRecurring(db, today, now, created)]
let autoSent = 0

@ -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
})
})

@ -476,3 +476,32 @@ Founder call ("start all of it", functional first). Built the five gaps the dail
- **Client statement** + **AWS cost UI**: frontend over existing ledger / AWS endpoints.
Backend committed green (350 tests); UIs follow. Ops (Gmail connect, server secrets, DB
password out of source, backups) are the next slice after the features.
## D27 — Review-screen program: Renewals, Cockpit, Audit viewer (2026-07-18)
Founder steer mid-program: "it's a small business — the main thing we need is screens we
can review to and fro." So we built read-and-navigate screens and dropped the heavy
automation (bank reconciliation, WhatsApp adapter, GST e-invoice/GSP — too much machinery
for ~300 clients). Delivered: **Renewals** command centre (`renewalsDue`, one-click renewal
proforma), owner **Cockpit** (`misCockpit`, this-month-vs-last composed from the same repo
aggregates the Reports/Dashboard use), **Audit viewer** (`listAuditPage`/`auditFacets`,
owner-only, filter + honest pagination, reading writes no audit).
## D28 — SMS credit review + live gateway balance sync (2026-07-19)
The SMS module already declares an `sms_balance` service field (D26); this makes it
actionable. **SMS balances** screen lists every SMS client lowest-first, "Low" (< the dated
`sms.low_balance_threshold`) is the default upsell view, one click raises a top-up proforma,
and the owner Cockpit + Dashboard carry the low-count alert. **Ticket SLA**: an Age column,
`ticket.sla_days` (dated) drives an overdue mark + filter, and a "Bill" action opens New
Document with the client pre-selected (light ticket→invoice; `/documents/new?client=`).
**Live balance fetch** (founder: "configure the BalAlert API so we see the current count,
daily auto + low alert"): the provider panel (`sms.balance_api_url`, default
`http://164.52.195.161/API/BalAlert.aspx`) returns pipe-delimited `code|STATUS|payload`;
`parseBalanceResponse` reads the trailing number, flags ERROR lines, never guesses. Each SMS
subscription's already-imported `username`/`password_enc` are used — the automated path
decrypts directly (no reveal-audit spam) and audits `sms_balance` **only when it changes**;
per-poll telemetry (ok/error + time) lives in the unaudited `sms_balance_check` table
(operational log, not domain data). A managerial "Refresh from gateway" button polls all;
the scheduler runs one pass per calendar day (`maybeRefreshSmsDaily`, claims the day via a
setting). **Requires `HQ_SECRET_KEY`** set to the import-time key to decrypt — without it the
refresh no-ops with a clear message, it never stores anything unencrypted.

Loading…
Cancel
Save