Files
pn-new-crm/tests/unit/deal-health.test.ts
Matt 6b28459c45 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

171 lines
5.2 KiB
TypeScript

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