// 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() }) })