Files
pn-new-crm/tests/unit/deal-health.test.ts

171 lines
5.2 KiB
TypeScript
Raw Permalink Normal View History

feat(pipeline): 9→7 stage refactor + v1.1 hardening wave Replaces the legacy 9-stage pipeline with 7 canonical stages (enquiry → qualified → eoi → reservation → deposit_paid → contract → nurturing) plus three doc sub-status columns (eoi_doc_status, reservation_doc_status, contract_doc_status) that track sent/signed within a single stage instead of branching it. Schema (migration 0062): - interests gains assigned_to, deposit_expected_amount/currency, three doc-status columns, two documenso-id columns, and date_reservation_signed. - New tables: qualification_criteria (per-port admin-configurable), interest_qualifications (per-interest state), payments (deposit / balance / refund records keyed to interest + client). - Default qualification criteria seeded for every existing port. - Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into the new stage + doc-status + outcome shape. Migration 0063 adds interest_contact_log.voice_transcript and template_used columns for v1.1-A/B (quick-template buttons + voice transcription via Web Speech API). v1.1 phase work bundled here: - A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on the contact-log compose dialog (useVoiceTranscription hook). - C: berth-rules-engine wraps state writes in pg_advisory_xact_lock with an idempotent re-read; emits rule_evaluated audit traces. - D: Documenso webhook: reservation/contract sub-status stamping moved out of the PDF-download try-block so a download failure no longer swallows the stamp. New integration test coverage. - E: /admin/qualification-criteria CRUD page + admin component. - F: default_new_interest_owner exposed in System Settings. - G: recentActivityCount + active_engagement deal-pulse signal surfaced as a chip on interests + hot-deals card. - H: interest_assigned notification on assignedTo change (skips self-assign, uses a dedupe key). Plus the supporting components: AssignedToChip, DealPulseChip, PaymentsSection, QualificationChecklist, MultiEoiChip, SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner, SupplementalInfoRequestButton, UserPicker. Tests: 1370/1370 vitest pass (added deal-health unit suite + expanded constants/validators/pipeline-transitions coverage). tsc clean, eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
import { describe, it, expect } from 'vitest';
import { computeDealHealth } from '@/lib/services/deal-health';
const daysAgo = (n: number): string => new Date(Date.now() - n * 86_400_000).toISOString();
describe('computeDealHealth', () => {
it('returns baseline 50 + warm pulse when no signals fire', () => {
const r = computeDealHealth({ pipelineStage: 'enquiry' });
expect(r.score).toBe(50);
expect(r.pulse).toBe('warm');
expect(r.signals).toEqual([]);
});
it('returns neutral baseline when interest is closed/won', () => {
const r = computeDealHealth({
pipelineStage: 'contract',
outcome: 'won',
dateLastContact: daysAgo(2),
});
expect(r.score).toBe(50);
expect(r.signals).toEqual([]);
});
it('returns neutral baseline when interest is archived', () => {
const r = computeDealHealth({
pipelineStage: 'eoi',
archivedAt: daysAgo(10),
dateLastContact: daysAgo(2),
});
expect(r.signals).toEqual([]);
});
it('+20 for contact in last 7 days', () => {
const r = computeDealHealth({
pipelineStage: 'enquiry',
dateLastContact: daysAgo(3),
});
const sig = r.signals.find((s) => s.id === 'contact_recent');
expect(sig?.delta).toBe(20);
expect(r.score).toBeGreaterThanOrEqual(70);
expect(r.pulse).toBe('hot');
});
it('+10 for contact within 14 days', () => {
const r = computeDealHealth({
pipelineStage: 'enquiry',
dateLastContact: daysAgo(10),
});
const sig = r.signals.find((s) => s.id === 'contact_warm');
expect(sig?.delta).toBe(10);
});
it('-15 when no contact in 30+ days', () => {
const r = computeDealHealth({
pipelineStage: 'qualified',
dateLastContact: daysAgo(40),
});
const sig = r.signals.find((s) => s.id === 'contact_stale');
expect(sig?.delta).toBe(-15);
});
it('-10 when EOI sent >14d ago and still unsigned', () => {
const r = computeDealHealth({
pipelineStage: 'eoi',
dateEoiSent: daysAgo(20),
eoiDocStatus: 'sent',
});
const sig = r.signals.find((s) => s.id === 'eoi_awaiting');
expect(sig?.delta).toBe(-10);
});
it('does NOT penalize EOI awaiting when sub-status is signed', () => {
const r = computeDealHealth({
pipelineStage: 'eoi',
dateEoiSent: daysAgo(20),
eoiDocStatus: 'signed',
});
expect(r.signals.find((s) => s.id === 'eoi_awaiting')).toBeUndefined();
});
it('-10 when reservation signed >21d ago but no deposit', () => {
const r = computeDealHealth({
pipelineStage: 'reservation',
dateReservationSigned: daysAgo(25),
});
const sig = r.signals.find((s) => s.id === 'deposit_pending');
expect(sig?.delta).toBe(-10);
});
it('does NOT penalize deposit_pending when deposit already received', () => {
const r = computeDealHealth({
pipelineStage: 'deposit_paid',
dateReservationSigned: daysAgo(25),
dateDepositReceived: daysAgo(2),
});
expect(r.signals.find((s) => s.id === 'deposit_pending')).toBeUndefined();
});
it('stage progress bonus caps at +30', () => {
const r = computeDealHealth({
pipelineStage: 'contract',
contractDocStatus: 'signed',
});
const sig = r.signals.find((s) => s.id === 'stage_progress');
expect(sig?.delta).toBeLessThanOrEqual(30);
expect(sig?.delta).toBeGreaterThan(0);
});
it('-10 for deals stuck pre-EOI for 30+ days', () => {
const r = computeDealHealth({
pipelineStage: 'enquiry',
dateFirstContact: daysAgo(45),
});
const sig = r.signals.find((s) => s.id === 'stuck_top_funnel');
expect(sig?.delta).toBe(-10);
});
it('clamps score to [0, 100]', () => {
const r = computeDealHealth({
pipelineStage: 'contract',
dateLastContact: daysAgo(2),
contractDocStatus: 'signed',
});
expect(r.score).toBeLessThanOrEqual(100);
expect(r.score).toBeGreaterThanOrEqual(0);
});
it('rates a fresh contract-signed deal as hot', () => {
const r = computeDealHealth({
pipelineStage: 'contract',
dateLastContact: daysAgo(1),
contractDocStatus: 'signed',
});
expect(r.pulse).toBe('hot');
});
it('rates a stalled mid-funnel deal with no recent contact as cold', () => {
const r = computeDealHealth({
pipelineStage: 'qualified',
dateFirstContact: daysAgo(60),
dateLastContact: daysAgo(45),
});
expect(r.pulse).toBe('cold');
});
it('+5 active_engagement when recentActivityCount >= 1', () => {
const r = computeDealHealth({
pipelineStage: 'enquiry',
recentActivityCount: 3,
});
const sig = r.signals.find((s) => s.id === 'active_engagement');
expect(sig?.delta).toBe(5);
});
it('skips active_engagement when recentActivityCount is null/undefined', () => {
const r = computeDealHealth({
pipelineStage: 'enquiry',
recentActivityCount: null,
});
expect(r.signals.find((s) => s.id === 'active_engagement')).toBeUndefined();
});
it('skips active_engagement when recentActivityCount is 0', () => {
const r = computeDealHealth({
pipelineStage: 'enquiry',
recentActivityCount: 0,
});
expect(r.signals.find((s) => s.id === 'active_engagement')).toBeUndefined();
});
});