feat(onboarding): link advance/balance payment steps to real payments

Milestone.paymentId + project_milestone.payment_id (SQLite + Postgres,
additive). setMilestone(..., paymentId?) resolves the payment's
received_on to stamp done_on when a payment-step tick links a real
payment; the linked payment must belong to the same client. api.ts
threads paymentId from the milestone POST body.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 days ago
parent b2c49832d5
commit cd235b622b

@ -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) })

@ -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)
}

@ -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;`,
},
]

@ -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<Milestone[]> {
const rows = await db.all<MilestoneRow>(
`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<Milestone[]> {
return db.transaction(async () => {
const before = await db.get<MilestoneRow>(
`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)
})

@ -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)
})
})
Loading…
Cancel
Save