chore: remove all 'Tecnostac' references — everything is SiMS

Founder: there is no Tecnostac in this application. Replaced the seed default
company.name ('Tecnostac' -> 'SiMS'), demo-seed data, ~22 test fixtures/emails, and
docs (Tecnostac -> SiMS, @tecnostac.com -> @sims.com). Live DB company.name also
updated to 'SiMS' (shows on dashboard, documents, letterhead, reminder emails). Owner
email was already SiMS (simssoftware13@gmail.com). Full suite 416 green.
feat/client-detail-redesign
Thomas Joise 2 days ago
parent b065332f89
commit 11d45e805c

@ -58,7 +58,7 @@ node apps/hq/dist/server.cjs # first boot on an empty data/ prints a one-ti
cd apps/hq-web && npm run dev
```
- Owner login on first boot: `admin@tecnostac.com` + the one-time password printed in the
- Owner login on first boot: `admin@sims.com` + the one-time password printed in the
server log (change it immediately). The "Gmail disconnected" banner is expected until Gmail is connected.
- Env: `DATABASE_URL` (set → Postgres engine, absent → SQLite at `HQ_DATA_DIR`; D19),
`SIMS_PIN_PEPPER` (set once, never change), `GOOGLE_*` (Gmail), `AWS_*` (Cost Explorer),

@ -88,7 +88,7 @@ npm install # from the repo root (workspaces: packages/*, ap
npm run build --workspace @sims/hq
node apps/hq/dist/server.cjs # serves :5182 (API, built web UI, /share/:token)
# First boot on an empty data/ creates data/hq.db and prints a one-time owner
# password — capture it. Log in as admin@tecnostac.com, then change it.
# password — capture it. Log in as admin@sims.com, then change it.
# Frontend — hq-web dev server (Vite on :5183, proxies /api and /share to :5182)
cd apps/hq-web && npm run dev

@ -1,5 +1,5 @@
/**
* Demo seed populates a fresh HQ_DATA_DIR with a realistic Tecnostac dataset so the
* Demo seed populates a fresh HQ_DATA_DIR with a realistic SiMS dataset so the
* app can be shown with live screens. Run: HQ_DATA_DIR=<dir> npx tsx scripts/demo-seed.ts
* Not shipped; a presentation helper.
*/
@ -17,7 +17,7 @@ void (async () => {
const db = openDb(process.env['HQ_DATA_DIR'])
// Known owner so we can log in for screenshots (before seedIfEmpty, so it skips its own).
await createStaff(db, { email: 'owner@tecnostac.com', displayName: 'Thomas (Owner)', role: 'owner', password: 'demo-owner-2026' })
await createStaff(db, { email: 'owner@sims.com', displayName: 'Thomas (Owner)', role: 'owner', password: 'demo-owner-2026' })
await seedIfEmpty(db)
const U = 'demo'
@ -28,20 +28,20 @@ void (async () => {
// Company + template letterhead data (what the Document Template page edits).
const settings: Record<string, string> = {
'company.name': 'Tecnostac Solutions Pvt Ltd',
'company.name': 'SiMS Solutions Pvt Ltd',
'company.address': '2nd Floor, Cyber Park, Kakkanad, Kochi, Kerala 682030',
'company.gstin': '32ABCDE1234F1Z9',
'company.state_code': '32',
'company.phone': '+91 98470 12345',
'company.email': 'accounts@tecnostac.com',
'company.email': 'accounts@sims.com',
'company.bank': 'HDFC Bank · A/c 50200012345678 · IFSC HDFC0001234 · Kakkanad',
'template.accent': '#1e3a5f',
'template.logo': LOGO,
'template.terms': '1. Prices are valid for 15 days from the date of this quotation.\n2. 50% advance on confirmation, balance before go-live.\n3. Annual subscription renews on the anniversary of activation.',
'template.declaration': 'We declare that this invoice shows the actual price of the services described and that all particulars are true and correct.',
'template.jurisdiction': 'Subject to Ernakulam jurisdiction',
'template.footer_note': 'Thank you for your business. For support: support@tecnostac.com · +91 98470 12345',
'template.signatory_label': 'For Tecnostac Solutions Pvt Ltd',
'template.footer_note': 'Thank you for your business. For support: support@sims.com · +91 98470 12345',
'template.signatory_label': 'For SiMS Solutions Pvt Ltd',
}
for (const [k, v] of Object.entries(settings)) await setSetting(db, U, k, v)
@ -100,7 +100,7 @@ void (async () => {
const shareToken = ((await db.get<{ token: string }>(`SELECT token FROM document_share LIMIT 1`))!).token
console.log('DEMO_READY')
console.log('login: owner@tecnostac.com / demo-owner-2026')
console.log('login: owner@sims.com / demo-owner-2026')
console.log('quotation_id:', qtIssued.id, 'doc_no:', qtIssued.docNo)
console.log('invoice_id:', inv1.id, 'doc_no:', inv1.docNo)
console.log('share_token:', shareToken)

@ -15,7 +15,7 @@ import { SCHEDULE_DEFAULTS, type ScheduleRuleKind } from './repos-reminders'
const OWNER_EMAIL = process.env['HQ_OWNER_EMAIL']?.trim() || 'simssoftware13@gmail.com'
const SETTING_DEFAULTS: Record<string, string> = {
'company.name': 'Tecnostac',
'company.name': 'SiMS',
'company.address': '',
'company.gstin': '',
'company.state_code': '32', // Kerala — founder to confirm

@ -6,9 +6,9 @@ import { createStaff, login, verifySession } from '../src/auth'
describe('hq auth', () => {
it('logs in with correct password, rejects wrong one', async () => {
const db = openDb(':memory:')
await createStaff(db, { email: 'admin@tecnostac.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9' })
expect(await login(db, 'admin@tecnostac.com', 'wrong')).toMatchObject({ ok: false, reason: 'invalid' })
const ok = await login(db, 'admin@tecnostac.com', 'let-me-in-9')
await createStaff(db, { email: 'admin@sims.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9' })
expect(await login(db, 'admin@sims.com', 'wrong')).toMatchObject({ ok: false, reason: 'invalid' })
const ok = await login(db, 'admin@sims.com', 'let-me-in-9')
expect(ok.ok).toBe(true)
const staff = await verifySession(db, ok.ok ? ok.token : '')
expect(staff).toMatchObject({ role: 'owner' })

@ -26,7 +26,7 @@ function bounceFetch(recipient: string): typeof fetch {
describe('pollBounces', () => {
it('flips the sent email_log to bounced and raises an email_bounced reminder, idempotently', async () => {
const db = openDb(':memory:'); await seedIfEmpty(db)
await saveAccount(db, 'us@tecnostac.com', encrypt('rt', KEY))
await saveAccount(db, 'us@sims.com', encrypt('rt', KEY))
await logEmail(db, { to: 'ravi@acme.in', subject: 'INV/26-27-0001', status: 'sent', gmailMessageId: 'g1' })
const deps: BounceDeps = {
gmail: { f: bounceFetch('ravi@acme.in'), clientId: 'cid', clientSecret: 'sec', keyHex: KEY },

@ -17,7 +17,7 @@ import { apiRouter } from '../src/api'
async function withOwner(): Promise<{ db: DB; ownerId: string }> {
const db = openDb(':memory:')
const { id } = await createStaff(db, {
email: 'admin@tecnostac.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9',
email: 'admin@sims.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9',
})
return { db, ownerId: id }
}

@ -89,7 +89,7 @@ async function appWith(fetchImpl: typeof fetch) {
const db = openDb(':memory:')
const { c, m } = await base(db)
await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
await saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
await saveAccount(db, 'us@sims.com', encrypt('refresh-token', KEY))
const pi = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
})).id)

@ -14,7 +14,7 @@ import { runDailyScan, type ScanDeps } from '../src/scheduler'
const scanDeps: ScanDeps = {
gmail: { f: (async () => new Response('{}')) as typeof fetch, clientId: '', clientSecret: '', keyHex: '' },
renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'Tecnostac' }), now: () => '2026-07-10T00:00:00Z',
renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'SiMS' }), now: () => '2026-07-10T00:00:00Z',
}
async function seeded() {

@ -13,7 +13,7 @@ import { runDailyScan, type ScanDeps } from '../src/scheduler'
const deps: ScanDeps = {
gmail: { f: (async () => new Response('{}')) as typeof fetch, clientId: '', clientSecret: '', keyHex: '' },
renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'Tecnostac' }),
renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'SiMS' }),
now: () => '2026-07-17T00:00:00Z',
}
@ -122,7 +122,7 @@ describe('due dates (D18)', () => {
const inv = await issueDocument(db, 'u1', (await invoiceDraft()).id)
await db.run(`UPDATE document SET due_date='2026-07-02' WHERE id=?`, inv.id)
const { id } = await upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: 'd15', clientId: c.id, docId: inv.id, now: '2026-07-17T00:00:00Z' })
const { ctx } = await reminderContext(db, (await getReminder(db, id))!, 'Tecnostac', '2026-07-17')
const { ctx } = await reminderContext(db, (await getReminder(db, id))!, 'SiMS', '2026-07-17')
expect(ctx.dueDate).toBe('2026-07-02')
expect(ctx.daysOverdue).toBe(15)
expect(reminderEmail('invoice_overdue', ctx).bodyText).toMatch(/was due on 2026-07-02 and is now 15 day\(s\) overdue/)
@ -133,7 +133,7 @@ describe('due dates (D18)', () => {
const inv = await issueDocument(db, 'u1', (await invoiceDraft()).id)
await db.run(`UPDATE document SET doc_date='2026-07-07', due_date=NULL WHERE id=?`, inv.id)
const { id } = await upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: 'd7', clientId: c.id, docId: inv.id, now: '2026-07-17T00:00:00Z' })
const { ctx } = await reminderContext(db, (await getReminder(db, id))!, 'Tecnostac', '2026-07-17')
const { ctx } = await reminderContext(db, (await getReminder(db, id))!, 'SiMS', '2026-07-17')
expect(ctx.dueDate).toBeUndefined()
expect(reminderEmail('invoice_overdue', ctx).bodyText).toMatch(/is outstanding and is now 10 day\(s\) past due/)
})

@ -12,7 +12,7 @@ import { listAudit } from '../src/audit'
async function withOwner(): Promise<{ db: DB; ownerId: string }> {
const db = openDb(':memory:')
const { id } = await createStaff(db, {
email: 'admin@tecnostac.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9',
email: 'admin@sims.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9',
})
return { db, ownerId: id }
}

@ -94,7 +94,7 @@ async function appWith(fetchImpl: typeof fetch) {
const { db, m, mkClient } = await world()
await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
await createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' })
await saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
await saveAccount(db, 'us@sims.com', encrypt('refresh-token', KEY))
const withMail = await mkClient('Acme Bank', 'ravi@acme.in')
const noMail = await mkClient('Beta Coop') // no contact email — must be reported, not skipped silently
await assignModule(db, 'u1', { clientId: withMail.id, moduleId: m.id, kind: 'yearly' })

@ -17,7 +17,7 @@ const sampleClient = { id: 'c1', code: 'X', name: 'X', stateCode: '32', address:
describe('templates: screen paper styles + documentHtmlSample', () => {
it('documentHtml carries screen-only paper styles (PDF unaffected — print media)', () => {
expect(documentHtml(sampleDoc, sampleClient, { 'company.name': 'Tecnostac' })).toContain('@media screen')
expect(documentHtml(sampleDoc, sampleClient, { 'company.name': 'SiMS' })).toContain('@media screen')
})
it('documentHtmlSample renders letterhead, bank box and given bullets through the one renderer', () => {
const html = documentHtmlSample(

@ -26,13 +26,13 @@ const okFetch = (async (url: string) =>
const deps: ScanDeps & SendReminderDeps = {
gmail: { f: okFetch, clientId: 'cid', clientSecret: 'sec', keyHex: KEY },
renderPdf: async () => Buffer.from('%PDF-fake'),
company: () => ({ 'company.name': 'Tecnostac' }),
company: () => ({ 'company.name': 'SiMS' }),
now: () => '2026-07-10T09:00:00Z',
}
async function world() {
const db = openDb(':memory:'); await seedIfEmpty(db)
await saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
await saveAccount(db, 'us@sims.com', encrypt('refresh-token', KEY))
await setSetting(db, 'u1', 'share.base_url', 'https://hq.example.in') // sends refuse a dead relative link without it
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] })
const m = await createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' })
@ -203,9 +203,9 @@ describe('quote_followup context, preview and send', () => {
await runDailyScan(db, deps, '2026-07-04')
const rem = (await followups(db))[0]!
const auditBefore = ((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM audit_log`))!).n
const { ctx } = await reminderContext(db, rem, 'Tecnostac')
const { ctx } = await reminderContext(db, rem, 'SiMS')
const mail = reminderEmail('quote_followup', ctx)
expect(mail.subject).toContain('Tecnostac')
expect(mail.subject).toContain('SiMS')
expect(await shareCount(db)).toBe(0) // resolve-only: preview NEVER mints
expect(((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM audit_log`))!).n).toBe(auditBefore)
})
@ -213,7 +213,7 @@ describe('quote_followup context, preview and send', () => {
const { db, c, m } = await world()
const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z') // never issued: docNo is null (F10)
await runDailyScan(db, deps, '2026-07-04')
const { ctx } = await reminderContext(db, (await followups(db))[0]!, 'Tecnostac')
const { ctx } = await reminderContext(db, (await followups(db))[0]!, 'SiMS')
const mail = reminderEmail('quote_followup', ctx)
expect(mail.subject).not.toContain('null')
expect(mail.bodyText).not.toContain('null')
@ -225,7 +225,7 @@ describe('quote_followup context, preview and send', () => {
const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
const issued = await issueDocument(db, 'u1', q.id)
await runDailyScan(db, deps, '2026-07-04')
const { ctx } = await reminderContext(db, (await followups(db))[0]!, 'Tecnostac')
const { ctx } = await reminderContext(db, (await followups(db))[0]!, 'SiMS')
expect(reminderEmail('quote_followup', ctx).subject).toContain(issued.docNo!)
})
it('send resolves the dated subject/body on the injected clock, not the wall clock', async () => {
@ -238,9 +238,9 @@ describe('quote_followup context, preview and send', () => {
await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
await runDailyScan(db, deps, '2026-07-04')
const rem = (await followups(db))[0]!
const before = await reminderContext(db, rem, 'Tecnostac', '2026-07-10')
const before = await reminderContext(db, rem, 'SiMS', '2026-07-10')
expect(reminderEmail('quote_followup', before.ctx).subject).not.toContain('FUTURE')
const after = await reminderContext(db, rem, 'Tecnostac', '2026-07-11')
const after = await reminderContext(db, rem, 'SiMS', '2026-07-11')
expect(reminderEmail('quote_followup', after.ctx).subject).toContain('FUTURE')
})
it('refuses to send when share.base_url is unset — loud error, NO share minted, row still queued', async () => {
@ -284,7 +284,7 @@ describe('quote_followup context, preview and send', () => {
expect(out).toEqual({ ok: true })
expect(await shareCount(db)).toBe(1)
// The sent body carries a live share link.
const { ctx } = await reminderContext(db, (await getReminder(db, first.id))!, 'Tecnostac')
const { ctx } = await reminderContext(db, (await getReminder(db, first.id))!, 'SiMS')
expect(reminderEmail('quote_followup', ctx).bodyText).toContain('/share/')
await runDailyScan(db, deps, '2026-07-08') // d7
const second = (await followups(db)).find((r) => r.duePeriod === 'd7')!

@ -45,7 +45,7 @@ describe('payment receipts', () => {
it('renders a receipt acknowledgment (no SAC/GST table)', async () => {
const { db, c, inv } = await setup()
const out = await recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 11_800_00, allocations: [{ documentId: inv.id, amountPaise: 11_800_00 }], issueReceipt: true })
const html = documentHtml(out.receipt!, c, { 'company.name': 'Tecnostac' })
const html = documentHtml(out.receipt!, c, { 'company.name': 'SiMS' })
expect(html).toContain('RECEIPT')
expect(html).toContain('Received with thanks')
expect(html).not.toContain('>SAC<') // the GST line table header is absent on receipts

@ -11,7 +11,7 @@ import { upsertReminder } from '../src/repos-reminders'
import { apiRouter } from '../src/api'
async function appWith() {
const db = openDb(':memory:'); await seedIfEmpty(db) // company.name='Tecnostac'
const db = openDb(':memory:'); await seedIfEmpty(db) // company.name='SiMS'
await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const c = await createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
@ -40,8 +40,8 @@ describe('GET /reminders/:id/preview', () => {
const token = await login()
const r = await get(token, ctx.overdueId)
expect(r.status).toBe(200)
expect(r.json.subject).toContain(ctx.inv.docNo) // 'Payment reminder — Invoice INV/26-27-0001 (Tecnostac)'
expect(r.json.subject).toContain('Tecnostac')
expect(r.json.subject).toContain(ctx.inv.docNo) // 'Payment reminder — Invoice INV/26-27-0001 (SiMS)'
expect(r.json.subject).toContain('SiMS')
expect(r.json.body).toContain('Acme') // clientName
})
it('404s an unknown reminder and 400s an internal follow_up', async () => {

@ -23,13 +23,13 @@ const okFetch = (async (url: string) =>
const deps: ScanDeps & SendReminderDeps = {
gmail: { f: okFetch, clientId: 'cid', clientSecret: 'sec', keyHex: KEY },
renderPdf: async () => Buffer.from('%PDF-fake'),
company: () => ({ 'company.name': 'Tecnostac' }),
company: () => ({ 'company.name': 'SiMS' }),
now: () => '2026-07-10T09:00:00Z',
}
async function world() {
const db = openDb(':memory:'); await seedIfEmpty(db)
await saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
await saveAccount(db, 'us@sims.com', encrypt('refresh-token', KEY))
await setSetting(db, 'u1', 'share.base_url', 'https://hq.example.in')
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] })
const m = await createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' })

@ -9,7 +9,7 @@ import {
describe('reminder templates', () => {
it('renders an overdue dunning email with the amount and a polite tone', () => {
const mail = reminderEmail('invoice_overdue', {
clientName: 'Malabar Stores', companyName: 'Tecnostac',
clientName: 'Malabar Stores', companyName: 'SiMS',
docNo: 'INV/26-27-0007', amountPaise: 11_800_00, daysOverdue: 12,
})
expect(mail.subject).toContain('INV/26-27-0007')

@ -17,12 +17,12 @@ const okFetch = (async (url: string) =>
const deadFetch = (async () => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 })) as typeof fetch
function deps(f: typeof fetch): ScanDeps {
return { gmail: { f, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'Tecnostac' }), now: () => '2026-07-10T00:00:00Z' }
return { gmail: { f, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'SiMS' }), now: () => '2026-07-10T00:00:00Z' }
}
async function world() {
const db = openDb(':memory:'); await seedIfEmpty(db)
await saveAccount(db, 'us@tecnostac.com', encrypt('rt', KEY))
await saveAccount(db, 'us@sims.com', encrypt('rt', KEY))
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
const m = await createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud', allowedKinds: ['monthly'] })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'monthly', pricePaise: 2_000_00, effectiveFrom: '2026-01-01' })

@ -14,7 +14,7 @@ import { runDailyScan, type ScanDeps } from '../src/scheduler'
const deps: ScanDeps = {
gmail: { f: (async () => new Response('{}')) as typeof fetch, clientId: '', clientSecret: '', keyHex: '' },
renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'Tecnostac' }),
renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'SiMS' }),
now: () => '2026-07-10T00:00:00Z',
}
@ -112,7 +112,7 @@ describe('invoice_overdue — dN milestone escalation (Phase 9, spec §8)', () =
it('the email renders "N day(s) past due" from ctx.daysOverdue', async () => {
const { db, c, inv } = await agedInvoice('2026-06-25') // 15 days before the 10th
const { id } = await upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: 'd15', clientId: c.id, docId: inv.id, now: '2026-07-10T00:00:00Z' })
const { ctx } = await reminderContext(db, (await getReminder(db, id))!, 'Tecnostac', '2026-07-10')
const { ctx } = await reminderContext(db, (await getReminder(db, id))!, 'SiMS', '2026-07-10')
expect(ctx.daysOverdue).toBe(15)
expect(reminderEmail('invoice_overdue', ctx).bodyText).toMatch(/15 day\(s\) past due/)
})

@ -12,7 +12,7 @@ import { sendReminder, type SendReminderDeps } from '../src/send-reminder'
const KEY = '11'.repeat(32)
const fakePdf = async () => Buffer.from('%PDF-fake')
const company = () => ({ 'company.name': 'Tecnostac' })
const company = () => ({ 'company.name': 'SiMS' })
function deps(f: typeof fetch): SendReminderDeps {
return { gmail: { f, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, renderPdf: fakePdf, company, now: () => '2026-07-10T09:00:00Z' }
@ -20,7 +20,7 @@ function deps(f: typeof fetch): SendReminderDeps {
async function invoiceSetup() {
const db = openDb(':memory:'); await seedIfEmpty(db)
await saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
await saveAccount(db, 'us@sims.com', encrypt('refresh-token', KEY))
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })

@ -30,10 +30,10 @@ describe('company profile settings', () => {
it('owner reads and updates company.* and it is audited', async () => {
const token = await login('owner@test.in', 'owner-password')
const before = await call(token, 'GET', '/settings/company')
expect(before.json.company['company.name']).toBe('Tecnostac')
const put = await call(token, 'PUT', '/settings/company', { name: 'Tecnostac Pvt Ltd', phone: '0484-1234567', stateCode: '32' })
expect(before.json.company['company.name']).toBe('SiMS')
const put = await call(token, 'PUT', '/settings/company', { name: 'SiMS Pvt Ltd', phone: '0484-1234567', stateCode: '32' })
expect(put.status).toBe(200)
expect(put.json.company['company.name']).toBe('Tecnostac Pvt Ltd')
expect(put.json.company['company.name']).toBe('SiMS Pvt Ltd')
expect(put.json.company['company.phone']).toBe('0484-1234567')
const audits = (await ctx.db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='setting' AND entity_id LIKE 'company.%'`))!
expect(audits.n).toBeGreaterThanOrEqual(3)

@ -31,7 +31,7 @@ describe('template settings', () => {
it('owner GET returns company.* + template.*; PUT writes template fields + a title, each audited', async () => {
const token = await login('owner@test.in', 'owner-password')
const before = await call(token, 'GET', '/settings/template')
expect(before.json.settings['company.name']).toBe('Tecnostac')
expect(before.json.settings['company.name']).toBe('SiMS')
const put = await call(token, 'PUT', '/settings/template', {
template: { footerNote: 'Thank you!', declaration: 'We declare…', signatoryLabel: 'Proprietor' },
titles: { INVOICE: 'GST INVOICE' },

@ -14,7 +14,7 @@ const client = { id: 'c1', code: 'CL1', name: 'Acme', stateCode: '32', address:
describe('template.accent (validated hex, single source of truth)', () => {
it('defaults to slate-indigo #334155 when unset', () => {
const html = documentHtml(base, client, { 'company.name': 'Tecnostac' })
const html = documentHtml(base, client, { 'company.name': 'SiMS' })
expect(html).toContain('--accent:#334155')
})
it('applies a valid hex accent verbatim (6- and 3-digit)', () => {

@ -14,7 +14,7 @@ const client = { id: 'c1', code: 'CL1', name: 'Acme', stateCode: '32', address:
describe('template.* rendering (default-fallback)', () => {
it('renders built-in defaults when template.* is unset (nothing breaks pre-config)', () => {
const html = documentHtml(invoice, client, { 'company.name': 'Tecnostac' })
const html = documentHtml(invoice, client, { 'company.name': 'SiMS' })
expect(html).toContain('TAX INVOICE') // built-in title
expect(html).toContain('Authorised Signatory') // built-in signatory label
expect(html).not.toContain('class="declaration"')
@ -24,7 +24,7 @@ describe('template.* rendering (default-fallback)', () => {
})
it('applies each template.* override where set', () => {
const html = documentHtml(invoice, client, {
'company.name': 'Tecnostac',
'company.name': 'SiMS',
'template.title_INVOICE': 'GST INVOICE',
'template.declaration': 'We declare the particulars are true.',
'template.jurisdiction': 'Subject to Kochi jurisdiction',

@ -12,7 +12,7 @@ const doc = {
} as never
const client = { id: 'c1', code: 'CL0001', name: 'Acme', stateCode: '32', address: 'Kochi',
contacts: [], status: 'active', notes: '' } as never
const company = { 'company.name': 'Tecnostac', 'company.gstin': '', 'company.address': '',
const company = { 'company.name': 'SiMS', 'company.gstin': '', 'company.address': '',
'company.phone': '', 'company.email': '', 'company.bank': 'HDFC ****1234' }
describe('document html', () => {

@ -59,7 +59,7 @@ npm i -g pm2
env $(grep -v '^#' apps/hq/.env | xargs) pm2 start apps/hq/dist/server.cjs --name sims-hq
pm2 save && pm2 startup
```
Owner login on first boot is `admin@tecnostac.com` + the printed password (change it
Owner login on first boot is `admin@sims.com` + the printed password (change it
immediately). The Gmail-disconnected banner is expected until step 6.
## 4. Put HTTPS in front (never expose Node directly)

@ -411,9 +411,9 @@ import { createStaff, login, verifySession } from '../src/auth'
describe('hq auth', () => {
it('logs in with correct password, rejects wrong one', () => {
const db = openDb(':memory:')
createStaff(db, { email: 'admin@tecnostac.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9' })
expect(login(db, 'admin@tecnostac.com', 'wrong')).toBeNull()
const ok = login(db, 'admin@tecnostac.com', 'let-me-in-9')
createStaff(db, { email: 'admin@sims.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9' })
expect(login(db, 'admin@sims.com', 'wrong')).toBeNull()
const ok = login(db, 'admin@sims.com', 'let-me-in-9')
expect(ok).not.toBeNull()
const staff = verifySession(db, ok!.token)
expect(staff).toMatchObject({ role: 'owner' })
@ -912,7 +912,7 @@ const doc = {
} as never
const client = { id: 'c1', code: 'CL0001', name: 'Acme', stateCode: '32', address: 'Kochi',
contacts: [], status: 'active', notes: '' } as never
const company = { 'company.name': 'Tecnostac', 'company.gstin': '', 'company.address': '',
const company = { 'company.name': 'SiMS', 'company.gstin': '', 'company.address': '',
'company.phone': '', 'company.email': '', 'company.bank': 'HDFC ****1234' }
describe('document html', () => {
@ -1031,7 +1031,7 @@ describe('gmail connect', () => {
**Interfaces:**
- Produces:
- `seedIfEmpty(db): void` — if no `staff_user`: owner `admin@tecnostac.com` with a random 12-char password **printed to console once**; settings `company.*` placeholders + `company.state_code='32'`; `tax_class` row `('GST18', 1800, '2017-07-01')`.
- `seedIfEmpty(db): void` — if no `staff_user`: owner `admin@sims.com` with a random 12-char password **printed to console once**; settings `company.*` placeholders + `company.state_code='32'`; `tax_class` row `('GST18', 1800, '2017-07-01')`.
- `stageCsv(db, kind: 'clients'|'invoices', csvText: string): { staged: number }` — tiny CSV parser (header row, comma, double-quote escaping — ~20 lines, no dependency); per-row problems collected into `problems` JSON: missing name, GSTIN checksum failure, bad date, duplicate code/doc_no.
- `verificationReport(db): { clients: { staged: number; problems: number }; invoices: { staged: number; problems: number; totalPaise: number }; samples: { firstClients: string[]; lastInvoices: string[] } }`
- `commitImport(db, userId): { clients: number; invoices: number; seeded: { fy: string; lastSeq: number } | null }` — inserts clients (`source='apex'`), invoices as issued documents (`source='apex'`, status `paid` when `paid=1` else `sent`, payload lines empty — history-only), then **seeds the INVOICE series**: parse the max numeric tail of current-FY staged doc numbers → `seedSeries(db,'INVOICE', fy, lastSeq)`. Refuses to run when any staged row has problems.

@ -1001,7 +1001,7 @@ import {
describe('reminder templates', () => {
it('renders an overdue dunning email with the amount and a polite tone', () => {
const mail = reminderEmail('invoice_overdue', {
clientName: 'Malabar Stores', companyName: 'Tecnostac',
clientName: 'Malabar Stores', companyName: 'SiMS',
docNo: 'INV/26-27-0007', amountPaise: 11_800_00, daysOverdue: 12,
})
expect(mail.subject).toContain('INV/26-27-0007')
@ -1302,7 +1302,7 @@ import { sendReminder, type SendReminderDeps } from '../src/send-reminder'
const KEY = '11'.repeat(32)
const fakePdf = async () => Buffer.from('%PDF-fake')
const company = () => ({ 'company.name': 'Tecnostac' })
const company = () => ({ 'company.name': 'SiMS' })
function deps(f: typeof fetch): SendReminderDeps {
return { gmail: { f, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, renderPdf: fakePdf, company, now: () => '2026-07-10T09:00:00Z' }
@ -1310,7 +1310,7 @@ function deps(f: typeof fetch): SendReminderDeps {
function invoiceSetup() {
const db = openDb(':memory:'); seedIfEmpty(db)
saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
saveAccount(db, 'us@sims.com', encrypt('refresh-token', KEY))
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
@ -1587,7 +1587,7 @@ import { runDailyScan, type ScanDeps } from '../src/scheduler'
const deps: ScanDeps = {
gmail: { f: (async () => new Response('{}')) as typeof fetch, clientId: '', clientSecret: '', keyHex: '' },
renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'Tecnostac' }),
renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'SiMS' }),
now: () => '2026-07-10T00:00:00Z',
}
@ -1789,12 +1789,12 @@ const okFetch = (async (url: string) =>
const deadFetch = (async () => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 })) as typeof fetch
function deps(f: typeof fetch): ScanDeps {
return { gmail: { f, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'Tecnostac' }), now: () => '2026-07-10T00:00:00Z' }
return { gmail: { f, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'SiMS' }), now: () => '2026-07-10T00:00:00Z' }
}
function world() {
const db = openDb(':memory:'); seedIfEmpty(db)
saveAccount(db, 'us@tecnostac.com', encrypt('rt', KEY))
saveAccount(db, 'us@sims.com', encrypt('rt', KEY))
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
const m = createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud', allowedKinds: ['monthly'] })
setPrice(db, 'u1', { moduleId: m.id, kind: 'monthly', pricePaise: 2_000_00, effectiveFrom: '2026-01-01' })
@ -2010,7 +2010,7 @@ function bounceFetch(recipient: string): typeof fetch {
describe('pollBounces', () => {
it('flips the sent email_log to bounced and raises an email_bounced reminder, idempotently', async () => {
const db = openDb(':memory:'); seedIfEmpty(db)
saveAccount(db, 'us@tecnostac.com', encrypt('rt', KEY))
saveAccount(db, 'us@sims.com', encrypt('rt', KEY))
logEmail(db, { to: 'ravi@acme.in', subject: 'INV/26-27-0001', status: 'sent', gmailMessageId: 'g1' })
const deps: BounceDeps = {
gmail: { f: bounceFetch('ravi@acme.in'), clientId: 'cid', clientSecret: 'sec', keyHex: KEY },
@ -2198,7 +2198,7 @@ import { runDailyScan, type ScanDeps } from '../src/scheduler'
const scanDeps: ScanDeps = {
gmail: { f: (async () => new Response('{}')) as typeof fetch, clientId: '', clientSecret: '', keyHex: '' },
renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'Tecnostac' }), now: () => '2026-07-10T00:00:00Z',
renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'SiMS' }), now: () => '2026-07-10T00:00:00Z',
}
function seeded() {

@ -945,7 +945,7 @@ describe('payment receipts', () => {
it('renders a receipt acknowledgment (no SAC/GST table)', () => {
const { db, c, inv } = setup()
const out = recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 11_800_00, allocations: [{ documentId: inv.id, amountPaise: 11_800_00 }], issueReceipt: true })
const html = documentHtml(out.receipt!, c, { 'company.name': 'Tecnostac' })
const html = documentHtml(out.receipt!, c, { 'company.name': 'SiMS' })
expect(html).toContain('RECEIPT')
expect(html).toContain('Received with thanks')
expect(html).not.toContain('>SAC<') // the GST line table header is absent on receipts
@ -1225,10 +1225,10 @@ describe('company profile settings', () => {
it('owner reads and updates company.* and it is audited', async () => {
const token = await login('owner@test.in', 'owner-password')
const before = await call(token, 'GET', '/settings/company')
expect(before.json.company['company.name']).toBe('Tecnostac')
const put = await call(token, 'PUT', '/settings/company', { name: 'Tecnostac Pvt Ltd', phone: '0484-1234567', stateCode: '32' })
expect(before.json.company['company.name']).toBe('SiMS')
const put = await call(token, 'PUT', '/settings/company', { name: 'SiMS Pvt Ltd', phone: '0484-1234567', stateCode: '32' })
expect(put.status).toBe(200)
expect(put.json.company['company.name']).toBe('Tecnostac Pvt Ltd')
expect(put.json.company['company.name']).toBe('SiMS Pvt Ltd')
expect(put.json.company['company.phone']).toBe('0484-1234567')
const audits = db.prepare(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='setting' AND entity_id LIKE 'company.%'`).get() as { n: number }
expect(audits.n).toBeGreaterThanOrEqual(3)

@ -562,7 +562,7 @@ const client = { id: 'c1', code: 'CL1', name: 'Acme', stateCode: '32', address:
describe('template.* rendering (default-fallback)', () => {
it('renders built-in defaults when template.* is unset (nothing breaks pre-config)', () => {
const html = documentHtml(invoice, client, { 'company.name': 'Tecnostac' })
const html = documentHtml(invoice, client, { 'company.name': 'SiMS' })
expect(html).toContain('TAX INVOICE') // built-in title
expect(html).toContain('Authorised Signatory') // built-in signatory label
expect(html).not.toContain('class="declaration"')
@ -572,7 +572,7 @@ describe('template.* rendering (default-fallback)', () => {
})
it('applies each template.* override where set', () => {
const html = documentHtml(invoice, client, {
'company.name': 'Tecnostac',
'company.name': 'SiMS',
'template.title_INVOICE': 'GST INVOICE',
'template.declaration': 'We declare the particulars are true.',
'template.jurisdiction': 'Subject to Kochi jurisdiction',
@ -719,7 +719,7 @@ const sampleClient = { id: 'c1', code: 'X', name: 'X', stateCode: '32', address:
describe('templates: screen paper styles + documentHtmlSample', () => {
it('documentHtml carries screen-only paper styles (PDF unaffected — print media)', () => {
expect(documentHtml(sampleDoc, sampleClient, { 'company.name': 'Tecnostac' })).toContain('@media screen')
expect(documentHtml(sampleDoc, sampleClient, { 'company.name': 'SiMS' })).toContain('@media screen')
})
it('documentHtmlSample renders letterhead, bank box and given bullets through the one renderer', () => {
const html = documentHtmlSample(
@ -950,7 +950,7 @@ describe('template settings', () => {
it('owner GET returns company.* + template.*; PUT writes template fields + a title, each audited', async () => {
const token = await login('owner@test.in', 'owner-password')
const before = await call(token, 'GET', '/settings/template')
expect(before.json.settings['company.name']).toBe('Tecnostac')
expect(before.json.settings['company.name']).toBe('SiMS')
const put = await call(token, 'PUT', '/settings/template', {
template: { footerNote: 'Thank you!', declaration: 'We declare…', signatoryLabel: 'Proprietor' },
titles: { INVOICE: 'GST INVOICE' },
@ -1086,7 +1086,7 @@ import { upsertReminder } from '../src/repos-reminders'
import { apiRouter } from '../src/api'
function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db) // company.name='Tecnostac'
const db = openDb(':memory:'); seedIfEmpty(db) // company.name='SiMS'
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS' })
@ -1114,8 +1114,8 @@ describe('GET /reminders/:id/preview', () => {
const token = await login()
const r = await get(token, ctx.overdueId)
expect(r.status).toBe(200)
expect(r.json.subject).toContain(ctx.inv.docNo) // 'Payment reminder — Invoice INV/26-27-0001 (Tecnostac)'
expect(r.json.subject).toContain('Tecnostac')
expect(r.json.subject).toContain(ctx.inv.docNo) // 'Payment reminder — Invoice INV/26-27-0001 (SiMS)'
expect(r.json.subject).toContain('SiMS')
expect(r.json.body).toContain('Acme') // clientName
})
it('404s an unknown reminder and 400s an internal follow_up', async () => {
@ -2121,7 +2121,7 @@ function QueueActions(props: { rem: Reminder & { docNo: string | null }; onDone:
```
- [ ] **Step 2: Browser-verify** (owner login → **Dashboard**, with at least one sendable reminder in the queue — an overdue invoice reminder is easiest to seed):
- Click **Preview** on an overdue reminder → the exact subject (e.g. `Payment reminder — Invoice INV/… (Tecnostac)`) and body appear inline, matching what Send would email; **Hide** collapses it.
- Click **Preview** on an overdue reminder → the exact subject (e.g. `Payment reminder — Invoice INV/… (SiMS)`) and body appear inline, matching what Send would email; **Hide** collapses it.
- Internal-only rows (follow-up / bounced) show no Preview/Send button (only Dismiss), unchanged.
- [ ] **Step 3: Typecheck + build + commit**`npm run typecheck` + `npm run build -w @sims/hq-web` clean.

@ -26,7 +26,7 @@
**Verify commands** (run from repo root `C:\SiMS\hq`):
- Typecheck: `npm run typecheck` → exits 0
- Tests: `npm test` → all pass
- Dev app: terminal A `cd apps/hq && npm start` (API :5182), terminal B `cd apps/hq-web && npm run dev` (Vite :5183). Login `admin@tecnostac.com` (owner password known to the user; ask them if a login is needed for visual checks).
- Dev app: terminal A `cd apps/hq && npm start` (API :5182), terminal B `cd apps/hq-web && npm run dev` (Vite :5183). Login `admin@sims.com` (owner password known to the user; ask them if a login is needed for visual checks).
---

@ -7,7 +7,7 @@
*Approved direction: "system upgrade" — Direction A v2's system discipline wearing H-04's
warm palette. Sources reviewed: `admin_design_v2.html` (a Claude Design handoff tar; the
chosen "Direction A v2" system), `H-04 Client 360 (timeline ribbon).html`, `Tecnostac
chosen "Direction A v2" system), `H-04 Client 360 (timeline ribbon).html`, `SiMS
Unified Workspace` promo, the small APEX card/loan-detail mockups, and the current
`@sims/ui` tokens. Companion docs: `2026-07-17-apex-82963-ux-reference.md` (UX checklist
honoured throughout), `2026-07-17-quote-to-close-funnel-design.md` (pages this shell must

@ -108,7 +108,7 @@ rebuildTable(db, table, newDDL, guardToken):
Employees [ + Add employee ]
────────────────────────────────────────────────────────────────────────────
Name Email Role Active Actions
Asha Rao asha@tecnostac.com owner ● Edit · Reset pw
Asha Rao asha@sims.com owner ● Edit · Reset pw
Vikram Shah vikram@… manager ● Edit · Reset pw · Deactivate
Priya Nair priya@… staff ○ Edit · Reset pw · Reactivate
────────────────────────────────────────────────────────────────────────────

Loading…
Cancel
Save