diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 9c20d8e..9eee431 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -731,7 +731,7 @@ export function apiRouter( res.status(404).json({ ok: false, error: 'Client module not found' }); return } try { - const body = req.body as { key?: unknown; done?: unknown; doneOn?: unknown; label?: unknown } + const body = req.body as { key?: unknown; done?: unknown; doneOn?: unknown; label?: unknown; paymentId?: unknown } if (typeof body.label === 'string') { // Add a project-specific milestone. res.json({ ok: true, milestones: await addProjectMilestone(db, staffId(res), id, body.label) }) @@ -741,7 +741,8 @@ export function apiRouter( throw new Error('Provide { key, done } to tick a milestone, or { label } to add one') } const milestones = await setMilestone(db, staffId(res), id, body.key, body.done, - typeof body.doneOn === 'string' ? body.doneOn : undefined) + typeof body.doneOn === 'string' ? body.doneOn : undefined, + typeof body.paymentId === 'string' ? body.paymentId : undefined) res.json({ ok: true, milestones }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index cd0cef8..8ba8410 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -516,6 +516,11 @@ function migrate(db: SqliteRaw): void { if (!modCols2.some((c) => c.name === 'sort_order')) { db.exec(`ALTER TABLE module ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 100`) } + // Client Detail redesign task 4: link an advance/balance payment-step tick to the real payment. + const milestoneCols = db.prepare(`PRAGMA table_info(project_milestone)`).all() as { name: string }[] + if (!milestoneCols.some((c) => c.name === 'payment_id')) { + db.exec(`ALTER TABLE project_milestone ADD COLUMN payment_id TEXT`) + } rebuildStaffUserRoleCheck(db) rebuildReminderRuleKindCheck(db) } diff --git a/apps/hq/src/migrations-pg.ts b/apps/hq/src/migrations-pg.ts index 36756e3..54abbd0 100644 --- a/apps/hq/src/migrations-pg.ts +++ b/apps/hq/src/migrations-pg.ts @@ -352,4 +352,9 @@ UPDATE client SET category = CASE END; `, }, + { + // Client Detail redesign task 4: link an advance/balance payment-step tick to the real payment. + id: '015-milestone-payment-id', + sql: `ALTER TABLE project_milestone ADD COLUMN IF NOT EXISTS payment_id text;`, + }, ] diff --git a/apps/hq/src/repos-milestones.ts b/apps/hq/src/repos-milestones.ts index 0703a51..93e5e52 100644 --- a/apps/hq/src/repos-milestones.ts +++ b/apps/hq/src/repos-milestones.ts @@ -9,8 +9,12 @@ import type { DB } from './db' * across the whole book: "who's pending go-live", "who hasn't paid the advance". */ -export interface Milestone { key: string; label: string; done: boolean; doneOn: string | null; sort: number } -interface MilestoneRow { key: string; label: string; done: number; done_on: string | null; sort: number } +export interface Milestone { + key: string; label: string; done: boolean; doneOn: string | null; sort: number; paymentId: string | null +} +interface MilestoneRow { + key: string; label: string; done: number; done_on: string | null; sort: number; payment_id: string | null +} export interface TemplateEntry { key: string; label: string } @@ -92,32 +96,52 @@ export async function ensureProjectMilestones(db: DB, clientModuleId: string): P export async function listProjectMilestones(db: DB, clientModuleId: string): Promise { const rows = await db.all( - `SELECT key, label, done, done_on, sort FROM project_milestone + `SELECT key, label, done, done_on, sort, payment_id FROM project_milestone WHERE client_module_id=? ORDER BY sort, label`, clientModuleId, ) - return rows.map((r) => ({ key: r.key, label: r.label, done: r.done === 1, doneOn: r.done_on, sort: r.sort })) + return rows.map((r) => ({ + key: r.key, label: r.label, done: r.done === 1, doneOn: r.done_on, sort: r.sort, paymentId: r.payment_id, + })) } -/** Tick / untick a milestone. Ticking stamps done_on (given date or today); unticking clears it. Audited. */ +/** + * Tick / untick a milestone. Ticking stamps done_on (given date or today); unticking clears + * it (and any linked payment). A payment-step tick (advance_payment / balance_payment) can + * pass `paymentId` to link a real payment — it must belong to the same client as this + * project, and its `received_on` mirrors into `done_on` unless an explicit `doneOn` is + * also given. Audited. + */ export async function setMilestone( - db: DB, userId: string, clientModuleId: string, key: string, done: boolean, doneOn?: string, + db: DB, userId: string, clientModuleId: string, key: string, done: boolean, + doneOn?: string, paymentId?: string, ): Promise { return db.transaction(async () => { const before = await db.get( - `SELECT key, label, done, done_on, sort FROM project_milestone WHERE client_module_id=? AND key=?`, + `SELECT key, label, done, done_on, sort, payment_id FROM project_milestone WHERE client_module_id=? AND key=?`, clientModuleId, key, ) if (before === undefined) throw new Error('Milestone not found on this project') if (done && doneOn !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(doneOn)) { throw new Error('doneOn must be YYYY-MM-DD') } - const stamp = done ? (doneOn ?? new Date().toISOString().slice(0, 10)) : null + let stamp = done ? (doneOn ?? new Date().toISOString().slice(0, 10)) : null + let linkedPayment: string | null = null + if (done && paymentId !== undefined && paymentId !== '') { + const pay = await db.get<{ received_on: string }>( + `SELECT received_on FROM payment WHERE id=? AND client_id=(SELECT client_id FROM client_module WHERE id=?)`, + paymentId, clientModuleId, + ) + if (pay === undefined) throw new Error('Payment not found for this client') + linkedPayment = paymentId + if (doneOn === undefined) stamp = pay.received_on // link date wins unless caller forced one + } await db.run( - `UPDATE project_milestone SET done=?, done_on=? WHERE client_module_id=? AND key=?`, - done ? 1 : 0, stamp, clientModuleId, key, + `UPDATE project_milestone SET done=?, done_on=?, payment_id=? WHERE client_module_id=? AND key=?`, + done ? 1 : 0, stamp, done ? linkedPayment : null, clientModuleId, key, ) - await writeAudit(db, userId, 'set_milestone', 'client_module', clientModuleId, undefined, { key, done, doneOn: stamp }) + await writeAudit(db, userId, 'set_milestone', 'client_module', clientModuleId, undefined, + { key, done, doneOn: stamp, paymentId: linkedPayment }) if (done) await advanceStatusForStep(db, userId, clientModuleId, key) return listProjectMilestones(db, clientModuleId) }) diff --git a/apps/hq/test/milestone-payment.test.ts b/apps/hq/test/milestone-payment.test.ts new file mode 100644 index 0000000..c2e6bd2 --- /dev/null +++ b/apps/hq/test/milestone-payment.test.ts @@ -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) + }) +})