fix(milestones): stamp installed_on/trained_on on every tick, keep links on a re-tick
Two bugs in the same tick path. 1. installed_on / trained_on were written only as a side effect of a status UPGRADE. The ~300 APEX-imported projects are already at 'live' -- the furthest status -- so ticking Installation or Training upgraded nothing and therefore recorded nothing, and the UI's own date writers were removed in the Client Detail redesign, leaving those columns unfillable and the Module Directory's "Installed" column blank forever. The date is now stamped whenever the step is ticked, independent of any status move: same transaction, same audit payload, still write-once (an existing date is never overwritten) and still no downgrade. A tick that changes neither status nor a date now writes nothing at all. 2. setMilestone wrote payment_id/document_id as `done ? linked : null`, so any tick that did not resend the ids cleared them. bulkSetMilestone routes through it and never passes ids (one key, many projects), so a single bulk re-tick silently cut every project's step loose from the payment or quotation it had been linked to. An already-done step now keeps the link it carries when nothing is supplied. An explicit empty id is still an unlink, and unticking still clears both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>main
parent
77f135f0e2
commit
4c1514c315
@ -0,0 +1,113 @@
|
||||
// apps/hq/test/milestone-link-preservation.test.ts — re-ticking an already-done step must not
|
||||
// wipe the payment / document link it already carries.
|
||||
//
|
||||
// The bug: setMilestone wrote `payment_id`/`document_id` as `done ? linked : null`, so any tick
|
||||
// that didn't resend the ids cleared them. bulkSetMilestone routes through setMilestone and
|
||||
// never passes ids at all (there is one key for many projects), so a single bulk re-tick — the
|
||||
// onboarding-cleanup tool — silently cut every project's step loose from the payment or
|
||||
// quotation it had been linked to one at a time. Unticking must still clear both.
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { openDb } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { createClient } from '../src/repos-clients'
|
||||
import { createModule, setPrice, assignModule } from '../src/repos-modules'
|
||||
import { createDraft } from '../src/repos-documents'
|
||||
import { recordPayment } from '../src/repos-payments'
|
||||
import { bulkSetMilestone, listProjectMilestones, 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' })
|
||||
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2020-01-01' })
|
||||
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
|
||||
const step = async (key: string) => (await listProjectMilestones(db, cm.id)).find((s) => s.key === key)!
|
||||
return { db, clientId: c.id, moduleId: m.id, cmId: cm.id, step }
|
||||
}
|
||||
|
||||
describe('bulk re-tick preserves the payment link (5a)', () => {
|
||||
it('tick with a payment link → bulk re-tick keeps it → bulk untick clears it', async () => {
|
||||
const { db, clientId, cmId, step } = await world()
|
||||
const pay = await recordPayment(db, 'u1', {
|
||||
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
|
||||
})
|
||||
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
|
||||
expect((await step('payment_received')).paymentId).toBe(pay.payment.id)
|
||||
|
||||
// The bulk tool carries no ids — the stored link must survive.
|
||||
const res = await bulkSetMilestone(db, 'u1', [cmId], 'payment_received', true, '2026-06-01')
|
||||
expect(res.updated).toBe(1)
|
||||
const after = await step('payment_received')
|
||||
expect(after.done).toBe(true)
|
||||
expect(after.paymentId).toBe(pay.payment.id)
|
||||
|
||||
// Unticking still cuts the link loose.
|
||||
await bulkSetMilestone(db, 'u1', [cmId], 'payment_received', false)
|
||||
const cleared = await step('payment_received')
|
||||
expect(cleared.done).toBe(false)
|
||||
expect(cleared.doneOn).toBeNull()
|
||||
expect(cleared.paymentId).toBeNull()
|
||||
})
|
||||
|
||||
it('a single re-tick that sends no ids keeps the link too', async () => {
|
||||
const { db, clientId, cmId, step } = await world()
|
||||
const pay = await recordPayment(db, 'u1', {
|
||||
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
|
||||
})
|
||||
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
|
||||
await setMilestone(db, 'u1', cmId, 'payment_received', true, '2026-06-01')
|
||||
const s = await step('payment_received')
|
||||
expect(s.doneOn).toBe('2026-06-01') // the date the caller did send still moves
|
||||
expect(s.paymentId).toBe(pay.payment.id)
|
||||
})
|
||||
|
||||
it('an explicit empty id is still an unlink, not a preserve', async () => {
|
||||
const { db, clientId, cmId, step } = await world()
|
||||
const pay = await recordPayment(db, 'u1', {
|
||||
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
|
||||
})
|
||||
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
|
||||
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, '')
|
||||
expect((await step('payment_received')).paymentId).toBeNull()
|
||||
})
|
||||
|
||||
it('the preserved link is what the audit row reports', 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, 'payment_received', true, undefined, pay.payment.id)
|
||||
await bulkSetMilestone(db, 'u1', [cmId], 'payment_received', true, '2026-06-01')
|
||||
const audits = await db.all<{ after_json: string | null }>(
|
||||
`SELECT after_json FROM audit_log WHERE action='set_milestone' ORDER BY id`,
|
||||
)
|
||||
expect(JSON.parse(audits.at(-1)!.after_json!)).toMatchObject({ paymentId: pay.payment.id })
|
||||
})
|
||||
})
|
||||
|
||||
describe('bulk re-tick preserves the document link (5a)', () => {
|
||||
it('tick with a quotation link → bulk re-tick keeps it → untick clears it', async () => {
|
||||
const { db, clientId, moduleId, cmId, step } = await world()
|
||||
const doc = await createDraft(db, 'u1', {
|
||||
docType: 'QUOTATION', clientId, lines: [{ moduleId, qty: 1, kind: 'yearly' }],
|
||||
})
|
||||
await setMilestone(db, 'u1', cmId, 'quotation_sent', true, '2026-05-01', undefined, doc.id)
|
||||
expect((await step('quotation_sent')).documentId).toBe(doc.id)
|
||||
|
||||
await bulkSetMilestone(db, 'u1', [cmId], 'quotation_sent', true, '2026-06-01')
|
||||
expect((await step('quotation_sent')).documentId).toBe(doc.id)
|
||||
|
||||
await bulkSetMilestone(db, 'u1', [cmId], 'quotation_sent', false)
|
||||
expect((await step('quotation_sent')).documentId).toBeNull()
|
||||
})
|
||||
|
||||
it('a step that was never done still starts with no link (nothing to preserve)', async () => {
|
||||
const { db, cmId, step } = await world()
|
||||
await bulkSetMilestone(db, 'u1', [cmId], 'quotation_sent', true, '2026-06-01')
|
||||
const s = await step('quotation_sent')
|
||||
expect(s.done).toBe(true)
|
||||
expect(s.documentId).toBeNull()
|
||||
expect(s.paymentId).toBeNull()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue