Compare commits
10 Commits
9ac2ce9d50
...
9d3087c1fe
| Author | SHA1 | Date |
|---|---|---|
|
|
9d3087c1fe | 2 days ago |
|
|
d05c7ee31b | 2 days ago |
|
|
67f69fc999 | 2 days ago |
|
|
75fb41c366 | 2 days ago |
|
|
195d821bef | 2 days ago |
|
|
fdf31b4dbd | 2 days ago |
|
|
4b2e44c3ea | 2 days ago |
|
|
6bce248c2e | 2 days ago |
|
|
cd235b622b | 2 days ago |
|
|
b2c49832d5 | 2 days ago |
@ -0,0 +1,19 @@
|
||||
// apps/hq/scripts/migrate-onboarding-templates.ts — Client Detail redesign task 5: one-time
|
||||
// migration swapping existing SMS/RTGS/MobileApp projects off the old generic onboarding
|
||||
// checklist onto their new per-module template, carrying mapped ticks. Idempotent — safe
|
||||
// to re-run (a second run does nothing).
|
||||
// DATABASE_URL=postgres://… npx tsx apps/hq/scripts/migrate-onboarding-templates.ts
|
||||
import { openDb } from '../src/db'
|
||||
import { openPgDb } from '../src/db-pg'
|
||||
import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates'
|
||||
|
||||
async function main() {
|
||||
const pgUrl = process.env['DATABASE_URL'] ?? ''
|
||||
const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
|
||||
|
||||
const res = await migrateOnboardingTemplates(db)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Onboarding template migration: ${res.projects} project(s) rebuilt, ${res.carried} tick(s) carried, ${res.dropped} dropped.`)
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1) })
|
||||
@ -0,0 +1,81 @@
|
||||
// apps/hq/src/migrate-onboarding-templates.ts — Client Detail redesign task 5: one-time
|
||||
// migration swapping seeded modules (SMS/RTGS/MobileApp) off the old generic onboarding
|
||||
// checklist onto their new per-module template (task 2), carrying mapped ticks
|
||||
// (done + done_on + payment_id) so onboarding history is not lost.
|
||||
import { uuidv7 } from '@sims/domain'
|
||||
import { writeAudit } from './audit'
|
||||
import { milestoneTemplate } from './repos-milestones'
|
||||
import type { DB } from './db'
|
||||
|
||||
/** old generic key -> new template key (only applied if the new template has that key). */
|
||||
const KEY_MAP: Record<string, string> = {
|
||||
installed: 'installation', go_live: 'go_live',
|
||||
advance_paid: 'advance_payment', balance_paid: 'balance_payment',
|
||||
setup_done: 'setup_configuration',
|
||||
}
|
||||
|
||||
interface OldRow { key: string; done: number; done_on: string | null; payment_id: string | null }
|
||||
|
||||
/**
|
||||
* Idempotent: for every client_module whose module has a per-module onboarding template
|
||||
* (a `project.milestone_template:<CODE>` setting — task 2), rebuild its milestone rows
|
||||
* from that template, carrying mapped ticks (done/done_on/payment_id) forward under their
|
||||
* new key. Unmapped old ticks (e.g. `amc_start`, or `setup_done` for a module whose new
|
||||
* template has no matching step) are dropped (and counted). Client_modules whose module
|
||||
* has no per-module template are left completely untouched.
|
||||
*/
|
||||
export async function migrateOnboardingTemplates(
|
||||
db: DB,
|
||||
): Promise<{ projects: number; carried: number; dropped: number }> {
|
||||
// Which module codes have a per-module template setting?
|
||||
const codes = (await db.all<{ key: string }>(
|
||||
`SELECT key FROM setting WHERE key LIKE 'project.milestone_template:%'`))
|
||||
.map((r) => r.key.slice('project.milestone_template:'.length))
|
||||
let projects = 0, carried = 0, dropped = 0
|
||||
for (const code of codes) {
|
||||
const template = await milestoneTemplate(db, code)
|
||||
const templateKeys = new Set(template.map((t) => t.key))
|
||||
const cms = await db.all<{ id: string }>(
|
||||
`SELECT cm.id FROM client_module cm JOIN module m ON m.id = cm.module_id WHERE m.code=?`, code)
|
||||
for (const { id: cmId } of cms) {
|
||||
const oldRows = await db.all<OldRow>(
|
||||
`SELECT key, done, done_on, payment_id FROM project_milestone WHERE client_module_id=?`, cmId)
|
||||
// Build the carried tick-state keyed by NEW key.
|
||||
const carriedState = new Map<string, OldRow>()
|
||||
let alreadyNew = true
|
||||
for (const r of oldRows) {
|
||||
if (!templateKeys.has(r.key)) alreadyNew = false
|
||||
const target = templateKeys.has(r.key) ? r.key : KEY_MAP[r.key]
|
||||
if (target !== undefined && templateKeys.has(target)) {
|
||||
if (r.done === 1) carriedState.set(target, r)
|
||||
} else if (r.done === 1) {
|
||||
dropped++
|
||||
}
|
||||
}
|
||||
// Idempotency: if the project's keys already ARE exactly the template, skip.
|
||||
const oldKeySet = new Set(oldRows.map((r) => r.key))
|
||||
const identical = alreadyNew && oldKeySet.size === templateKeys.size
|
||||
&& [...templateKeys].every((k) => oldKeySet.has(k))
|
||||
if (identical) continue
|
||||
projects++
|
||||
await db.transaction(async () => {
|
||||
await db.run(`DELETE FROM project_milestone WHERE client_module_id=?`, cmId)
|
||||
let sort = 0
|
||||
for (const t of template) {
|
||||
const c = carriedState.get(t.key)
|
||||
await db.run(
|
||||
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, payment_id, sort)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
uuidv7(), cmId, t.key, t.label,
|
||||
c !== undefined ? 1 : 0, c?.done_on ?? null, c?.payment_id ?? null, sort,
|
||||
)
|
||||
if (c !== undefined) carried++
|
||||
sort++
|
||||
}
|
||||
await writeAudit(db, 'system', 'migrate_onboarding', 'client_module', cmId, undefined,
|
||||
{ module: code, carried: [...carriedState.keys()] })
|
||||
})
|
||||
}
|
||||
}
|
||||
return { projects, carried, dropped }
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
// apps/hq/test/bulk-sms-purchase.test.ts — Task 7: ad-hoc bulk-SMS top-up documents,
|
||||
// tagged distinctly from subscription renewals via document.source.
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { openDb } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { createClient } from '../src/repos-clients'
|
||||
import { createModule, assignModule, setPrice, updateClientModule } from '../src/repos-modules'
|
||||
import { setUsageRateCard } from '../src/repos-rate-card'
|
||||
import { generateModuleRenewalQuote, generateBulkSmsPurchaseQuote } from '../src/repos-renewals'
|
||||
import { createDraft, getDocument } from '../src/repos-documents'
|
||||
|
||||
// The founder's real card: ₹0.40 / 0.37 / 0.34 / 0.30 per SMS = 40/37/34/30 paise.
|
||||
const BANDS = [
|
||||
{ minQty: 50_000, ratePaise: 40 },
|
||||
{ minQty: 100_000, ratePaise: 37 },
|
||||
{ minQty: 200_000, ratePaise: 34 },
|
||||
{ minQty: 300_000, ratePaise: 30 },
|
||||
]
|
||||
|
||||
async function world() {
|
||||
const db = openDb(':memory:')
|
||||
await seedIfEmpty(db) // company state 32, GST18
|
||||
const c = await createClient(db, 'u1', { name: 'Karapuzha SCB', stateCode: '32' })
|
||||
const sms = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS', allowedKinds: ['usage'] })
|
||||
await setUsageRateCard(db, 'u1', sms.id, '2026-04-01', BANDS)
|
||||
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: sms.id, kind: 'usage' })
|
||||
return { db, c, sms, cm }
|
||||
}
|
||||
|
||||
describe('generateBulkSmsPurchaseQuote', () => {
|
||||
it('raises a proforma tagged bulk_sms_topup for the given SMS quantity', async () => {
|
||||
const { db, cm } = await world()
|
||||
const doc = await generateBulkSmsPurchaseQuote(db, 'u1', cm.id, 100_000)
|
||||
expect(doc.docType).toBe('PROFORMA')
|
||||
expect(doc.source).toBe('bulk_sms_topup')
|
||||
expect(doc.payload.lines[0]!.qty).toBe(100_000)
|
||||
expect(doc.payablePaise).toBeGreaterThan(0)
|
||||
// Persisted correctly — a re-fetch from the DB carries the same tag.
|
||||
const full = (await getDocument(db, doc.id))!
|
||||
expect(full.source).toBe('bulk_sms_topup')
|
||||
})
|
||||
|
||||
it('rejects a non-positive or fractional quantity', async () => {
|
||||
const { db, cm } = await world()
|
||||
await expect(generateBulkSmsPurchaseQuote(db, 'u1', cm.id, 0)).rejects.toThrow()
|
||||
await expect(generateBulkSmsPurchaseQuote(db, 'u1', cm.id, -5)).rejects.toThrow()
|
||||
await expect(generateBulkSmsPurchaseQuote(db, 'u1', cm.id, 1.5)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rejects an unknown client module', async () => {
|
||||
const { db } = await world()
|
||||
await expect(generateBulkSmsPurchaseQuote(db, 'u1', 'nope', 100_000)).rejects.toThrow(/not found/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('document.source tagging', () => {
|
||||
it('tags a renewal proforma module_renewal, and a manual draft keeps the default', async () => {
|
||||
const { db, c } = await world()
|
||||
// A separate yearly-priced module for the renewal path (usage modules enforce a
|
||||
// minimum-order qty that a qty:1 renewal line would fail).
|
||||
const yearly = await createModule(db, 'u1', { code: 'CORE', name: 'Core App', allowedKinds: ['yearly'] })
|
||||
await setPrice(db, 'u1', { moduleId: yearly.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
|
||||
const cm2 = await assignModule(db, 'u1', { clientId: c.id, moduleId: yearly.id, kind: 'yearly' })
|
||||
await updateClientModule(db, 'u1', cm2.id, { nextRenewal: '2026-08-15' })
|
||||
const renewal = await generateModuleRenewalQuote(db, 'u1', cm2.id)
|
||||
expect(renewal.source).toBe('module_renewal')
|
||||
|
||||
const manual = await createDraft(db, 'u1', {
|
||||
docType: 'PROFORMA', clientId: c.id,
|
||||
lines: [{ moduleId: yearly.id, qty: 1, kind: 'yearly' }],
|
||||
})
|
||||
expect(manual.source).toBe('hq') // default preserved when source is omitted
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,182 @@
|
||||
// apps/hq/test/migrate-onboarding-templates.test.ts — Client Detail redesign task 5: one-time
|
||||
// migration swapping seeded modules (SMS/RTGS/MobileApp) from the old generic onboarding
|
||||
// checklist onto their new per-module templates, carrying mapped ticks (done/done_on/payment_id).
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { openDb, type DB } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { createClient } from '../src/repos-clients'
|
||||
import { createModule, assignModule } from '../src/repos-modules'
|
||||
import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates'
|
||||
import { listProjectMilestones } from '../src/repos-milestones'
|
||||
|
||||
describe('onboarding template migration', () => {
|
||||
let db: DB
|
||||
beforeEach(async () => { db = openDb(':memory:'); await seedIfEmpty(db) })
|
||||
|
||||
async function makeSmsClientModule(): Promise<string> {
|
||||
const client = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
|
||||
const mod = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
|
||||
const cm = await assignModule(db, 'u1', { clientId: client.id, moduleId: mod.id, kind: 'yearly' })
|
||||
// assignModule auto-seeds the NEW per-module template (task 2/4) — wipe it so the test
|
||||
// can plant the OLD generic rows a legacy project would actually have on disk.
|
||||
await db.run(`DELETE FROM project_milestone WHERE client_module_id=?`, cm.id)
|
||||
return cm.id
|
||||
}
|
||||
|
||||
async function seedOldGenericRows(cmId: string): Promise<void> {
|
||||
const rows: [string, number, string | null][] = [
|
||||
['advance_paid', 0, null], ['setup_done', 0, null], ['installed', 1, '2026-03-25'],
|
||||
['go_live', 1, '2026-04-02'], ['balance_paid', 0, null], ['amc_start', 0, null],
|
||||
]
|
||||
for (const [i, r] of rows.entries()) {
|
||||
await db.run(
|
||||
`INSERT INTO project_milestone (id,client_module_id,key,label,done,done_on,sort)
|
||||
VALUES (?,?,?,?,?,?,?)`,
|
||||
`m${i}`, cmId, r[0], String(r[0]), r[1], r[2], i,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
it('swaps an SMS project to the 9-step list carrying installed+go_live ticks', async () => {
|
||||
const cmId = await makeSmsClientModule()
|
||||
await seedOldGenericRows(cmId)
|
||||
|
||||
const res = await migrateOnboardingTemplates(db)
|
||||
expect(res.projects).toBe(1)
|
||||
expect(res.carried).toBe(2) // installed -> installation, go_live -> go_live
|
||||
expect(res.dropped).toBe(0) // the two dropped keys (setup_done, amc_start) were both undone
|
||||
|
||||
const ms = await listProjectMilestones(db, cmId)
|
||||
expect(ms.map((m) => m.key)).toEqual([
|
||||
'document_collection', 'dlt_registration', 'account_creation', 'installation',
|
||||
'testing', 'training', 'go_live', 'advance_payment', 'balance_payment',
|
||||
])
|
||||
const installation = ms.find((m) => m.key === 'installation')!
|
||||
expect(installation.done).toBe(true)
|
||||
expect(installation.doneOn).toBe('2026-03-25')
|
||||
expect(installation.paymentId).toBeNull()
|
||||
const goLive = ms.find((m) => m.key === 'go_live')!
|
||||
expect(goLive.done).toBe(true)
|
||||
expect(goLive.doneOn).toBe('2026-04-02')
|
||||
// untouched (never ticked) steps stay pending
|
||||
expect(ms.find((m) => m.key === 'document_collection')!.done).toBe(false)
|
||||
expect(ms.find((m) => m.key === 'advance_payment')!.done).toBe(false)
|
||||
expect(ms.find((m) => m.key === 'balance_payment')!.done).toBe(false)
|
||||
// old setup_done / amc_start gone entirely
|
||||
expect(ms.some((m) => m.key === 'setup_done' || m.key === 'amc_start')).toBe(false)
|
||||
|
||||
// audited
|
||||
const audits = await db.all<{ entity_id: string; after_json: string | null }>(
|
||||
`SELECT entity_id, after_json FROM audit_log WHERE action='migrate_onboarding'`,
|
||||
)
|
||||
expect(audits).toHaveLength(1)
|
||||
expect(audits[0]!.entity_id).toBe(cmId)
|
||||
expect(audits[0]!.after_json).toContain('installation')
|
||||
})
|
||||
|
||||
it('carries a done payment_id along with the mapped tick', async () => {
|
||||
const cmId = await makeSmsClientModule()
|
||||
const client = await db.get<{ client_id: string }>(
|
||||
`SELECT client_id FROM client_module WHERE id=?`, cmId,
|
||||
)
|
||||
const paymentId = 'pay-1'
|
||||
await db.run(
|
||||
`INSERT INTO payment (id, client_id, received_on, mode, amount_paise, created_by, created_at)
|
||||
VALUES (?, ?, '2026-03-01', 'bank', 500000, 'u1', '2026-03-01T00:00:00.000Z')`,
|
||||
paymentId, client!.client_id,
|
||||
)
|
||||
await db.run(
|
||||
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, payment_id, sort)
|
||||
VALUES (?,?,?,?,?,?,?,?)`,
|
||||
'm0', cmId, 'advance_paid', 'advance_paid', 1, '2026-03-01', paymentId, 0,
|
||||
)
|
||||
|
||||
await migrateOnboardingTemplates(db)
|
||||
const ms = await listProjectMilestones(db, cmId)
|
||||
const advance = ms.find((m) => m.key === 'advance_payment')!
|
||||
expect(advance.done).toBe(true)
|
||||
expect(advance.doneOn).toBe('2026-03-01')
|
||||
expect(advance.paymentId).toBe(paymentId)
|
||||
})
|
||||
|
||||
it('is idempotent (second run changes nothing new)', async () => {
|
||||
const cmId = await makeSmsClientModule()
|
||||
await seedOldGenericRows(cmId)
|
||||
|
||||
const first = await migrateOnboardingTemplates(db)
|
||||
expect(first.projects).toBe(1)
|
||||
const afterFirst = await listProjectMilestones(db, cmId)
|
||||
|
||||
const second = await migrateOnboardingTemplates(db)
|
||||
expect(second.projects).toBe(0)
|
||||
expect(second.carried).toBe(0)
|
||||
expect(second.dropped).toBe(0)
|
||||
const afterSecond = await listProjectMilestones(db, cmId)
|
||||
expect(afterSecond).toEqual(afterFirst)
|
||||
|
||||
// no extra audit rows written on the no-op second pass
|
||||
const audits = await db.all<{ n: number }>(
|
||||
`SELECT COUNT(*) AS n FROM audit_log WHERE action='migrate_onboarding'`,
|
||||
)
|
||||
expect((audits[0] as unknown as { n: number }).n).toBe(1)
|
||||
})
|
||||
|
||||
it('counts ticked dropped keys (unmapped or not in new template)', async () => {
|
||||
const cmId = await makeSmsClientModule()
|
||||
// Insert old generic rows with two ticked keys that will be dropped:
|
||||
// - amc_start: unmapped (not in KEY_MAP)
|
||||
// - setup_done: maps to 'setup_configuration', but SMS template doesn't have it
|
||||
// Plus one carried key (installed -> installation) to verify carried count too.
|
||||
const rows: [string, number, string | null][] = [
|
||||
['amc_start', 1, '2026-02-15'], // ticked, unmapped -> dropped
|
||||
['setup_done', 1, '2026-03-01'], // ticked, maps to setup_configuration but SMS template lacks it -> dropped
|
||||
['installed', 1, '2026-03-25'], // ticked, maps to installation which IS in SMS template -> carried
|
||||
['advance_paid', 0, null],
|
||||
['go_live', 0, null],
|
||||
['balance_paid', 0, null],
|
||||
]
|
||||
for (const [i, r] of rows.entries()) {
|
||||
await db.run(
|
||||
`INSERT INTO project_milestone (id,client_module_id,key,label,done,done_on,sort)
|
||||
VALUES (?,?,?,?,?,?,?)`,
|
||||
`m${i}`, cmId, r[0], String(r[0]), r[1], r[2], i,
|
||||
)
|
||||
}
|
||||
|
||||
const res = await migrateOnboardingTemplates(db)
|
||||
expect(res.projects).toBe(1)
|
||||
expect(res.carried).toBe(1) // only installed -> installation
|
||||
expect(res.dropped).toBe(2) // amc_start (unmapped) + setup_done (not in SMS template)
|
||||
|
||||
// Verify the dropped keys are gone from the migrated template
|
||||
const ms = await listProjectMilestones(db, cmId)
|
||||
expect(ms.map((m) => m.key)).toEqual([
|
||||
'document_collection', 'dlt_registration', 'account_creation', 'installation',
|
||||
'testing', 'training', 'go_live', 'advance_payment', 'balance_payment',
|
||||
])
|
||||
expect(ms.some((m) => m.key === 'amc_start' || m.key === 'setup_done')).toBe(false)
|
||||
|
||||
// Verify the carried installation tick is preserved
|
||||
const installation = ms.find((m) => m.key === 'installation')!
|
||||
expect(installation.done).toBe(true)
|
||||
expect(installation.doneOn).toBe('2026-03-25')
|
||||
})
|
||||
|
||||
it('leaves a module without a per-module template untouched', async () => {
|
||||
const client = await createClient(db, 'u1', { name: 'Other Society', stateCode: '32' })
|
||||
const mod = await createModule(db, 'u1', { code: 'NOTEMPLATE', name: 'No Template Module' })
|
||||
const cm = await assignModule(db, 'u1', { clientId: client.id, moduleId: mod.id, kind: 'yearly' })
|
||||
// This module has no project.milestone_template:NOTEMPLATE setting, so ensureProjectMilestones
|
||||
// fell back to the global generic template — the OLD generic rows themselves.
|
||||
const before = await listProjectMilestones(db, cm.id)
|
||||
expect(before.map((m) => m.key)).toEqual([
|
||||
'advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start',
|
||||
])
|
||||
|
||||
const res = await migrateOnboardingTemplates(db)
|
||||
expect(res.projects).toBe(0)
|
||||
|
||||
const after = await listProjectMilestones(db, cm.id)
|
||||
expect(after).toEqual(before)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,66 @@
|
||||
// apps/hq/test/milestone-payment.test.ts — payment_id on milestone + link on payment-step tick.
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { openDb } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { createClient } from '../src/repos-clients'
|
||||
import { createModule, assignModule } from '../src/repos-modules'
|
||||
import { setMilestone, listProjectMilestones } from '../src/repos-milestones'
|
||||
import { recordPayment } from '../src/repos-payments'
|
||||
|
||||
async function world() {
|
||||
const db = openDb(':memory:')
|
||||
await seedIfEmpty(db)
|
||||
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
|
||||
const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
|
||||
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
|
||||
return { db, clientId: c.id, cmId: cm.id }
|
||||
}
|
||||
|
||||
describe('milestone payment_id link (payment-step tick)', () => {
|
||||
it('linking a payment to advance_payment stores payment_id and mirrors received_on', async () => {
|
||||
const { db, clientId, cmId } = await world()
|
||||
const pay = await recordPayment(db, 'u1', {
|
||||
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
|
||||
})
|
||||
await setMilestone(db, 'u1', cmId, 'advance_payment', true, undefined, pay.payment.id)
|
||||
const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'advance_payment')!
|
||||
expect(ms.done).toBe(true)
|
||||
expect(ms.doneOn).toBe('2026-05-01')
|
||||
expect(ms.paymentId).toBe(pay.payment.id)
|
||||
})
|
||||
|
||||
it('rejects a payment that belongs to a different client', async () => {
|
||||
const { db, cmId } = await world()
|
||||
const other = await createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' })
|
||||
const pay = await recordPayment(db, 'u1', {
|
||||
clientId: other.id, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 500_00,
|
||||
})
|
||||
await expect(
|
||||
setMilestone(db, 'u1', cmId, 'advance_payment', true, undefined, pay.payment.id),
|
||||
).rejects.toThrow(/payment/i)
|
||||
})
|
||||
|
||||
it('unticking clears payment_id along with done_on', async () => {
|
||||
const { db, clientId, cmId } = await world()
|
||||
const pay = await recordPayment(db, 'u1', {
|
||||
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
|
||||
})
|
||||
await setMilestone(db, 'u1', cmId, 'advance_payment', true, undefined, pay.payment.id)
|
||||
await setMilestone(db, 'u1', cmId, 'advance_payment', false)
|
||||
const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'advance_payment')!
|
||||
expect(ms.done).toBe(false)
|
||||
expect(ms.doneOn).toBeNull()
|
||||
expect(ms.paymentId).toBeNull()
|
||||
})
|
||||
|
||||
it('an explicit doneOn wins over the payment received_on', async () => {
|
||||
const { db, clientId, cmId } = await world()
|
||||
const pay = await recordPayment(db, 'u1', {
|
||||
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
|
||||
})
|
||||
await setMilestone(db, 'u1', cmId, 'balance_payment', true, '2026-05-10', pay.payment.id)
|
||||
const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'balance_payment')!
|
||||
expect(ms.doneOn).toBe('2026-05-10')
|
||||
expect(ms.paymentId).toBe(pay.payment.id)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,45 @@
|
||||
// apps/hq/test/milestone-status.test.ts — milestone→status auto-drive (never downgrade).
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { openDb } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { createClient } from '../src/repos-clients'
|
||||
import { createModule, assignModule, getClientModule } from '../src/repos-modules'
|
||||
import { setMilestone } from '../src/repos-milestones'
|
||||
|
||||
async function world() {
|
||||
const db = openDb(':memory:')
|
||||
await seedIfEmpty(db)
|
||||
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
|
||||
const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
|
||||
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
|
||||
return { db, c, m, cmId: cm.id }
|
||||
}
|
||||
|
||||
describe('milestone → status auto-drive (D22 status line)', () => {
|
||||
it('ticking installation advances status to installed', async () => {
|
||||
const { db, cmId } = await world()
|
||||
await setMilestone(db, 'u1', cmId, 'installation', true)
|
||||
const cm = await getClientModule(db, cmId)
|
||||
expect(cm!.status).toBe('installed')
|
||||
})
|
||||
|
||||
it('ticking go_live advances status to live', async () => {
|
||||
const { db, cmId } = await world()
|
||||
await setMilestone(db, 'u1', cmId, 'go_live', true)
|
||||
expect((await getClientModule(db, cmId))!.status).toBe('live')
|
||||
})
|
||||
|
||||
it('unticking never downgrades status', async () => {
|
||||
const { db, cmId } = await world()
|
||||
await setMilestone(db, 'u1', cmId, 'go_live', true)
|
||||
await setMilestone(db, 'u1', cmId, 'go_live', false)
|
||||
expect((await getClientModule(db, cmId))!.status).toBe('live')
|
||||
})
|
||||
|
||||
it('a lower step never downgrades a higher status', async () => {
|
||||
const { db, cmId } = await world()
|
||||
await setMilestone(db, 'u1', cmId, 'go_live', true) // live
|
||||
await setMilestone(db, 'u1', cmId, 'installation', true) // still live
|
||||
expect((await getClientModule(db, cmId))!.status).toBe('live')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,57 @@
|
||||
// apps/hq/test/stalled-onboarding.test.ts — Task 8: stalled-onboarding query (dashboard/MIS tile).
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { openDb } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { createClient } from '../src/repos-clients'
|
||||
import { createModule, assignModule } from '../src/repos-modules'
|
||||
import { setMilestone, stalledProjects } from '../src/repos-milestones'
|
||||
|
||||
async function world() {
|
||||
const db = openDb(':memory:')
|
||||
await seedIfEmpty(db)
|
||||
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
|
||||
// A module code with no per-module milestone template seeded (D22 seed.ts only carries
|
||||
// SMS/RTGS/MOBILEAPP overrides), so the global template applies: advance_paid, setup_done,
|
||||
// installed, go_live, balance_paid, amc_start.
|
||||
const m = await createModule(db, 'u1', { code: 'MISC', name: 'Misc Module' })
|
||||
return { db, c, m }
|
||||
}
|
||||
|
||||
describe('stalledProjects (D22 — dashboard/MIS tile)', () => {
|
||||
it('lists a project parked past the threshold', async () => {
|
||||
const { db, c, m } = await world()
|
||||
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
|
||||
// Early steps done on an old date; later steps (incl. go_live) stay pending — project is
|
||||
// still active (status stays 'quoted'/'installed', never 'live').
|
||||
await setMilestone(db, 'u1', cm.id, 'advance_paid', true, '2026-03-01')
|
||||
await setMilestone(db, 'u1', cm.id, 'setup_done', true, '2026-03-01')
|
||||
// last done_on = 2026-03-01, today = 2026-07-19 → ~140 days parked.
|
||||
const stalled = await stalledProjects(db, 30, '2026-07-19')
|
||||
expect(stalled.map((p) => p.clientModuleId)).toContain(cm.id)
|
||||
const row = stalled.find((p) => p.clientModuleId === cm.id)!
|
||||
expect(row.clientName).toBe('Kothavara SCB')
|
||||
expect(row.moduleCode).toBe('MISC')
|
||||
})
|
||||
|
||||
it('excludes a recently-progressed project', async () => {
|
||||
const { db, c, m } = await world()
|
||||
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
|
||||
await setMilestone(db, 'u1', cm.id, 'advance_paid', true, '2026-03-01')
|
||||
await setMilestone(db, 'u1', cm.id, 'setup_done', true, '2026-03-01')
|
||||
const stalled = await stalledProjects(db, 365, '2026-07-19')
|
||||
expect(stalled).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('excludes projects with no pending steps (fully ticked) and inactive/live projects', async () => {
|
||||
const { db, c, m } = await world()
|
||||
// Fully ticked project: nothing pending, so it should never appear regardless of age.
|
||||
const done = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
|
||||
for (const key of ['advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start']) {
|
||||
await setMilestone(db, 'u1', done.id, key, true, '2026-01-01')
|
||||
}
|
||||
const stalled = await stalledProjects(db, 1, '2026-07-19')
|
||||
expect(stalled.map((p) => p.clientModuleId)).not.toContain(done.id)
|
||||
// 'live' status (from ticking go_live) also excludes it, which the loop above proves.
|
||||
expect((await db.get<{ status: string }>(`SELECT status FROM client_module WHERE id=?`, done.id))!.status).toBe('live')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue