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 cmId: string; clientId: string; clientName: string; clientCode: string
balanceText: string | null; balance: number | null; low: boolean balanceText: string | null; balance: number | null; low: boolean
reseller: string | null; status: string reseller: string | null; status: string
provider: string | null; hasCreds: boolean
checkStatus: string | null; checkMessage: string | null; checkedAt: string | null
} }
export interface SmsBalances { export interface SmsBalances {
rows: SmsBalanceRow[]; threshold: number rows: SmsBalanceRow[]; threshold: number
@ -933,6 +935,16 @@ export interface SmsBalances {
export const getSmsBalances = (): Promise<SmsBalances> => export const getSmsBalances = (): Promise<SmsBalances> =>
apiFetch<SmsBalances>('/reports/sms-balances') 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). // D27: audit log viewer (owner-only).
export interface AuditRow { export interface AuditRow {
id: string; at_wall: string; user_id: string; action: string id: string; at_wall: string; user_id: string; action: string

@ -1,14 +1,31 @@
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { formatINR } from '@sims/domain' import { formatINR } from '@sims/domain'
import { Badge, DataTable, EmptyState, ErrorState, Notice, PageHeader, Skeleton, StatCard, Stats } from '@sims/ui' import { Badge, DataTable, EmptyState, ErrorState, Notice, Skeleton, StatCard, Stats } from '@sims/ui'
import { getDashboard, isManagerial } from '../api' import { displayName, getDashboard, getSmsBalances, isManagerial } from '../api'
import { QueueActions, RULE_LABEL, reminderTone } from '../components/ReminderQueue' import { QueueActions, RULE_LABEL, reminderTone } from '../components/ReminderQueue'
import { useData } from './Clients' import { useData } from './Clients'
const inr = (p: number) => formatINR(p) 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() { export function Dashboard() {
const nav = useNavigate() const nav = useNavigate()
// My Day (D18 WS-E): owner/manager toggle Mine ↔ Everyone; staff are always // My Day (D18 WS-E): owner/manager toggle Mine ↔ Everyone; staff are always
@ -16,27 +33,52 @@ export function Dashboard() {
const managerial = isManagerial() const managerial = isManagerial()
const [mine, setMine] = useState(false) const [mine, setMine] = useState(false)
const dash = useData(() => getDashboard(mine), [mine]) 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 [err, setErr] = useState('')
const v = dash.data const v = dash.data
const name = displayName()
return ( return (
<div className="wf-page"> <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} />} {dash.error !== undefined && <ErrorState message={dash.error} onRetry={dash.reload} />}
{err !== '' && <Notice tone="err">{err}</Notice>} {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> <Stats>
<StatCard label="Overdue" value={inr(v.totals.overduePaise)} hint={`${v.overdue.length} invoice(s)`} /> <StatCard label="Overdue" value={inr(v.totals.overduePaise)} hint={`${v.overdue.length} invoice(s)`} />
<StatCard label="Queued reminders" value={String(v.totals.queued)} /> <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)} /> <StatCard label="Follow-ups today" value={String(v.followUpsToday.length)} />
</Stats> </Stats>
<h3 style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}> {/* Reminder queue — full send/preview/dismiss actions preserved. */}
Reminder queue <section className="dash-queue">
<Link to="/reminders" style={{ fontSize: 13, fontWeight: 500 }}>View all </Link> <div className="dash-head">
</h3> <h3>Reminder queue</h3>
{v.queue.length === 0 ? <EmptyState>Nothing waiting all clear.</EmptyState> : ( <span className="count">{v.queueTotal}</span>
<DataTable <Link to="/reminders">View all </Link>
columns={[ </div>
{ key: 'kind', label: 'Kind' }, { key: 'client', label: 'Client' }, {v.queue.length === 0 ? <EmptyState>Nothing waiting all clear.</EmptyState> : (
{ key: 'ref', label: 'Reference' }, { key: 'status', label: 'Status' }, <DataTable
{ key: 'error', label: 'Error' }, { key: 'act', label: '' }, columns={[
]} { key: 'kind', label: 'Kind' }, { key: 'client', label: 'Client' },
rowTone={(i) => { { key: 'ref', label: 'Reference' }, { key: 'status', label: 'Status' },
const s = v.queue[i]?.status { key: 'error', label: 'Error' }, { key: 'act', label: '' },
return s === 'failed' ? 'err' : undefined ]}
}} rowTone={(i) => (v.queue[i]?.status === 'failed' ? 'err' : undefined)}
rows={v.queue.map((rem) => ({ rows={v.queue.map((rem) => ({
kind: rem.label !== '' ? rem.label : RULE_LABEL[rem.ruleKind] ?? rem.ruleKind, kind: rem.label !== '' ? rem.label : RULE_LABEL[rem.ruleKind] ?? rem.ruleKind,
client: rem.clientName, client: rem.clientName,
ref: rem.docNo ?? rem.duePeriod, ref: rem.docNo ?? rem.duePeriod,
status: <Badge tone={reminderTone(rem.status)}>{rem.status}</Badge>, status: <Badge tone={reminderTone(rem.status)}>{rem.status}</Badge>,
error: rem.error ?? '—', error: rem.error ?? '—',
act: <QueueActions rem={rem} onDone={dash.reload} onError={setErr} />, act: <QueueActions rem={rem} onDone={dash.reload} onError={setErr} />,
}))} }))}
/> />
)} )}
{v.queueTotal > v.queue.length && ( {v.queueTotal > v.queue.length && (
// No silent caps (rule 8): the table is the first page only — say so. // 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)' }}> <p className="dash-cap">
Showing {v.queue.length} of {v.queueTotal} <Link to="/reminders">see the full queue</Link>. Showing {v.queue.length} of {v.queueTotal} <Link to="/reminders">see the full queue</Link>.
</p> </p>
)} )}
</section>
<Section title="Overdue" empty="No overdue invoices." rows={v.overdue} {/* Bento — the day's read-only sections. */}
columns={[{ key: 'no', label: 'Invoice' }, { key: 'client', label: 'Client' }, { key: 'days', label: 'Days', numeric: true }, { key: 'due', label: 'Outstanding', numeric: true }]} <div className="dash-bento">
onRowClick={(i) => nav(`/documents/${v.overdue[i]!.docId}`)} <Card title="Overdue invoices" count={v.overdue.length} empty="No overdue invoices." isEmpty={v.overdue.length === 0}>
map={(o) => ({ no: o.docNo ?? '—', client: o.clientName, days: o.daysOverdue, due: inr(o.outstandingPaise) })} /> {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"> <Card title="Due this week" count={v.dueThisWeek.length} empty="No recurring bills due this week." isEmpty={v.dueThisWeek.length === 0}>
<section> {v.dueThisWeek.map((d) => (
<Section title="Due this week" empty="No recurring bills due this week." rows={v.dueThisWeek} <Row key={d.planId} onClick={() => nav(`/clients/${d.clientId}`)}
columns={[{ key: 'client', label: 'Client' }, { key: 'on', label: 'Runs on' }, { key: 'amt', label: 'Amount', numeric: true }]} title={d.clientName} sub={`runs ${shortDate(d.nextRun)}`}
map={(d) => ({ client: d.clientName, on: d.nextRun, amt: d.amountPaise !== null ? inr(d.amountPaise) : 'from price book' })} /> {...(d.amountPaise !== null ? { right: inr(d.amountPaise) } : { rightSub: 'from price book' })} />
</section> ))}
</Card>
<section> <Card title="Renewals this month" count={v.renewalsThisMonth.length} link={{ to: '/renewals' }} empty="No renewals this month." isEmpty={v.renewalsThisMonth.length === 0}>
<Section title="Renewals this month" empty="No renewals this month." rows={v.renewalsThisMonth} {v.renewalsThisMonth.map((r) => (
columns={[{ key: 'client', label: 'Client' }, { key: 'on', label: 'Renews on' }]} <Row key={r.clientModuleId} onClick={() => nav(`/clients/${r.clientId}`)}
onRowClick={(i) => nav(`/clients/${v.renewalsThisMonth[i]!.clientId}`)} title={r.clientName} rightSub={shortDate(r.nextRenewal)} />
map={(r) => ({ client: r.clientName, on: r.nextRenewal })} /> ))}
</section> </Card>
<section> <Card title="Follow-ups today" count={v.followUpsToday.length} empty="No follow-ups due." isEmpty={v.followUpsToday.length === 0}>
<Section title="Follow-ups today" empty="No follow-ups due." rows={v.followUpsToday} {v.followUpsToday.map((f) => (
columns={[{ key: 'client', label: 'Client' }, { key: 'on', label: 'Due' }, { key: 'notes', label: 'Notes' }]} <Row key={f.id} onClick={() => nav(`/clients/${f.clientId}`)}
onRowClick={(i) => nav(`/clients/${v.followUpsToday[i]!.clientId}`)} title={f.clientName} sub={f.notes !== '' ? f.notes : '—'} />
map={(f) => ({ client: f.clientName, on: f.followUpOn, notes: f.notes !== '' ? f.notes : '—' })} /> ))}
</section> </Card>
<section> <Card title="Recent payments" count={v.recentPayments.length} empty="No payments recorded yet." isEmpty={v.recentPayments.length === 0}>
<Section title="Recent payments" empty="No payments recorded yet." rows={v.recentPayments} {v.recentPayments.map((p) => (
columns={[{ key: 'client', label: 'Client' }, { key: 'on', label: 'Received' }, { key: 'mode', label: 'Mode' }, { key: 'amt', label: 'Amount', numeric: true }]} <Row key={p.id} onClick={() => nav(`/clients/${p.clientId}`)}
map={(p) => ({ client: p.clientName, on: p.receivedOn, mode: p.mode, amt: inr(p.amountPaise) })} /> title={p.clientName} sub={`${shortDate(p.receivedOn)} · ${p.mode}`}
</section> right={inr(p.amountPaise)} />
))}
</Card>
</div> </div>
</> </>
)} )}
@ -114,23 +165,44 @@ export function Dashboard() {
) )
} }
/** A titled table with an empty state — keeps the dashboard body declarative. */ /** One bento card: an uppercase-labelled header (+ count and optional link) over a
function Section<T>(props: { * scrollable list, or an empty-state line when there's nothing to show. */
title: string; empty: string; rows: T[] function Card(props: {
columns: { key: string; label: string; numeric?: boolean }[] title: string; count?: number; link?: { to: string; label?: string }
map: (row: T) => Record<string, ReactNode>; onRowClick?: (i: number) => void empty: string; isEmpty: boolean; children: ReactNode
}) { }) {
return ( 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> <span className="rc">
{props.rows.length === 0 ? <EmptyState>{props.empty}</EmptyState> : ( <span className="t">{props.title}</span>
<DataTable {props.sub !== undefined && <span className="s">{props.sub}</span>}
columns={props.columns} </span>
{...(props.onRowClick !== undefined ? { onRowClick: (_r: unknown, i: number) => props.onRowClick!(i) } : {})} {(props.right !== undefined || props.rightSub !== undefined) && (
rows={props.rows.map(props.map)} <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 { import {
Badge, Button, DataTable, EmptyState, ErrorState, FilterChips, Notice, PageHeader, Skeleton, Toolbar, useToast, Badge, Button, DataTable, EmptyState, ErrorState, FilterChips, Notice, PageHeader, Skeleton, Toolbar, useToast,
} from '@sims/ui' } from '@sims/ui'
import { getSmsBalances, generateModuleRenewalQuote, type SmsBalanceRow } from '../api' import {
getSmsBalances, generateModuleRenewalQuote, refreshSmsBalances, isManagerial, type SmsBalanceRow,
} from '../api'
import { useData } from './Clients' import { useData } from './Clients'
type Scope = 'low' | 'all' | 'unknown' 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 * 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 * 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 toast = useToast()
const [scope, setScope] = useState<Scope>('low') const [scope, setScope] = useState<Scope>('low')
const [busy, setBusy] = useState<string | undefined>() const [busy, setBusy] = useState<string | undefined>()
const [refreshing, setRefreshing] = useState(false)
const list = useData(getSmsBalances, []) 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) => { const raiseTopup = (r: SmsBalanceRow) => {
if (busy !== undefined) return if (busy !== undefined) return
setBusy(r.cmId) setBusy(r.cmId)
@ -43,6 +71,9 @@ export function SmsBalances() {
<PageHeader <PageHeader
title="SMS balances" 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." 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' && ( {data !== undefined && data.lowCount > 0 && scope !== 'low' && (
<Notice tone="warn"> <Notice tone="warn">
@ -75,7 +106,8 @@ export function SmsBalances() {
columns={[ columns={[
{ key: 'client', label: 'Client' }, { key: 'client', label: 'Client' },
{ key: 'balance', label: 'Balance', numeric: true }, { key: 'balance', label: 'Balance', numeric: true },
{ key: 'reseller', label: 'Reseller' }, { key: 'provider', label: 'Provider' },
{ key: 'checked', label: 'Checked' },
{ key: 'actions', label: '' }, { key: 'actions', label: '' },
]} ]}
rowTone={(i) => (shown[i]!.low ? 'err' : undefined)} rowTone={(i) => (shown[i]!.low ? 'err' : undefined)}
@ -88,7 +120,12 @@ export function SmsBalances() {
{r.balance.toLocaleString('en-IN')} {r.balance.toLocaleString('en-IN')}
</Badge> </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: ( actions: (
<span onClick={(e) => e.stopPropagation()}> <span onClick={(e) => e.stopPropagation()}>
<Button tone="primary" disabled={busy !== undefined} onClick={() => raiseTopup(r)}> <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 { listUsageRateCards, setUsageRateCard } from './repos-rate-card'
import { renewalsDue, generateModuleRenewalQuote } from './repos-renewals' import { renewalsDue, generateModuleRenewalQuote } from './repos-renewals'
import { misCockpit } from './repos-mis' import { misCockpit } from './repos-mis'
import { smsBalances } from './repos-sms' import { smsBalances, refreshSmsBalance, refreshAllSmsBalances } from './repos-sms'
import { makeRateLimiter } from './rate-limit' import { makeRateLimiter } from './rate-limit'
import { import {
addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard, 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) }) 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) ---------- // ---------- audit log viewer (D27, owner-only) ----------
r.get('/audit', requireAuth, requireOwner, async (req, res) => { 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, created_by TEXT NOT NULL, created_at TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'hq' -- 'apex' rows are the imported history 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 ( CREATE TABLE IF NOT EXISTS tax_class (
class_code TEXT NOT NULL, rate_pct_bp INTEGER NOT NULL, 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 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; ALTER TABLE staff_user ADD COLUMN IF NOT EXISTS username text;
UPDATE staff_user SET username=email WHERE username IS NULL; UPDATE staff_user SET username=email WHERE username IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS ix_staff_username ON staff_user (username); 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 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 * 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 cmId: string; clientId: string; clientName: string; clientCode: string
balanceText: string | null; balance: number | null; low: boolean balanceText: string | null; balance: number | null; low: boolean
reseller: string | null; status: string 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 { export interface SmsBalances {
rows: SmsBalanceRow[]; threshold: number 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 } if (mod === undefined) return { rows: [], threshold, total: 0, lowCount: 0, unknownCount: 0 }
const raw = await db.all<{ const raw = await db.all<{
cm_id: string; client_id: string; client_name: string; client_code: string 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, `SELECT cm.id AS cm_id, cm.client_id, c.name AS client_name, c.code AS client_code,
cm.field_values, cm.status cm.field_values, cm.status, cm.provider, cm.username,
FROM client_module cm JOIN client c ON c.id = cm.client_id 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`, WHERE cm.module_id = ? AND cm.active = 1`,
mod.id, mod.id,
) )
@ -55,6 +68,9 @@ export async function smsBalances(db: DB): Promise<SmsBalances> {
balanceText, balance, low: balance !== null && balance < threshold, balanceText, balance, low: balance !== null && balance < threshold,
reseller: typeof fv.reseller === 'string' && fv.reseller.trim() !== '' ? fv.reseller.trim() : null, reseller: typeof fv.reseller === 'string' && fv.reseller.trim() !== '' ? fv.reseller.trim() : null,
status: r.status, 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. // 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, 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 { outstandingPaise } from './repos-payments'
import { CADENCE_KIND, getRecurringPlan } from './repos-recurring' import { CADENCE_KIND, getRecurringPlan } from './repos-recurring'
import { getNumberSetting, getSetting, resolveSchedule, setReminderStatus, upsertReminder } from './repos-reminders' import { getNumberSetting, getSetting, resolveSchedule, setReminderStatus, upsertReminder } from './repos-reminders'
import { maybeRefreshSmsDaily } from './repos-sms'
import { sendReminder, type SendReminderDeps } from './send-reminder' 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) --- // --- recurring_generated (transactional generation, then async auto-send) ---
const autoQueue = [...followupAuto, ...await generateRecurring(db, today, now, created)] const autoQueue = [...followupAuto, ...await generateRecurring(db, today, now, created)]
let autoSent = 0 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. - **Client statement** + **AWS cost UI**: frontend over existing ledger / AWS endpoints.
Backend committed green (350 tests); UIs follow. Ops (Gmail connect, server secrets, DB 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. 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