Lay the HubSpot and customer-lifecycle foundation
Work in progress from the Codex session, committed so nothing sits undeployed. Verified before committing: typecheck clean across all packages, 139 unit tests and the e2e suite green, migrations apply to an empty Postgres. Adds the HubSpot integration boundary (OAuth, client, contracts, webhook signature verification, sync), a growth route, customer-lifecycle service, Piggy lifecycle tools, a Growth page, and shared lifecycle/hubspot types. Two things are deliberately incomplete and should not be mistaken for finished: `packages/db/src/schema/hubspot.ts` is NOT exported from the schema index, so it is inert — no tables, no migration. That is the correct order (the shape can settle before it becomes a migration), but it does mean the HubSpot routes have no persistence behind them yet. `pnpm-workspace.yaml` and `pnpm-lock.yaml` are left uncommitted on purpose. The workspace file contains a literal unanswered placeholder — "esbuild: set this to true or false" — and this repository installs with npm, which is also what CI runs. Committing a second package manager's lockfile would make the install ambiguous. If the move to pnpm is intended it should be a deliberate change that updates CI and the Dockerfile together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
export const HUBSPOT_OBJECT_TYPES = ['companies', 'contacts', 'deals'] as const;
|
||||
export type HubSpotObjectType = (typeof HUBSPOT_OBJECT_TYPES)[number];
|
||||
|
||||
export const HUBSPOT_REQUIRED_SCOPES = [
|
||||
'crm.objects.companies.read',
|
||||
'crm.objects.contacts.read',
|
||||
'crm.objects.deals.read',
|
||||
] as const;
|
||||
export type HubSpotRequiredScope = (typeof HUBSPOT_REQUIRED_SCOPES)[number];
|
||||
|
||||
export const HUBSPOT_CONNECTION_STATUSES = [
|
||||
'active',
|
||||
'reauthorization_required',
|
||||
'disconnected',
|
||||
'error',
|
||||
] as const;
|
||||
export type HubSpotConnectionStatus = (typeof HUBSPOT_CONNECTION_STATUSES)[number];
|
||||
|
||||
export const HUBSPOT_SYNC_PHASES = ['initial', 'reconcile'] as const;
|
||||
export type HubSpotSyncPhase = (typeof HUBSPOT_SYNC_PHASES)[number];
|
||||
|
||||
export const HUBSPOT_EVENT_STATUSES = ['pending', 'processing', 'processed', 'failed'] as const;
|
||||
export type HubSpotEventStatus = (typeof HUBSPOT_EVENT_STATUSES)[number];
|
||||
|
||||
export const HUBSPOT_JOB_KINDS = ['full_sync', 'fetch_record', 'reconcile'] as const;
|
||||
export type HubSpotJobKind = (typeof HUBSPOT_JOB_KINDS)[number];
|
||||
|
||||
export const HUBSPOT_JOB_STATUSES = ['pending', 'processing', 'completed', 'failed'] as const;
|
||||
export type HubSpotJobStatus = (typeof HUBSPOT_JOB_STATUSES)[number];
|
||||
|
||||
export interface HubSpotRecord {
|
||||
id: string;
|
||||
properties: Record<string, string | null>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
export interface HubSpotRecordPage {
|
||||
results: HubSpotRecord[];
|
||||
nextAfter: string | null;
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export * from './margin';
|
||||
export * from './permissions';
|
||||
export * from './theme';
|
||||
export * from './imports';
|
||||
export * from './lifecycle';
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
import {
|
||||
DEMAND_OPEN_STAGES,
|
||||
type AllocationStatus,
|
||||
type ContractStatus,
|
||||
type DemandStage,
|
||||
type ProductLine,
|
||||
} from './ontology';
|
||||
|
||||
export const CUSTOMER_RELATIONSHIP_STATES = [
|
||||
'prospect',
|
||||
'contracted',
|
||||
'deployed',
|
||||
'former_customer',
|
||||
] as const;
|
||||
export type CustomerRelationshipState = (typeof CUSTOMER_RELATIONSHIP_STATES)[number];
|
||||
|
||||
export const GROWTH_FACETS = [
|
||||
'expansion_candidate',
|
||||
'renewal_due',
|
||||
'at_risk',
|
||||
'idle_supply_match',
|
||||
'coverage_gap',
|
||||
'data_stale',
|
||||
] as const;
|
||||
export type GrowthFacet = (typeof GROWTH_FACETS)[number];
|
||||
|
||||
export const GROWTH_SIGNAL_CATEGORIES = [
|
||||
'expansion',
|
||||
'renewal',
|
||||
'risk',
|
||||
'coverage',
|
||||
'supply',
|
||||
] as const;
|
||||
export type GrowthSignalCategory = (typeof GROWTH_SIGNAL_CATEGORIES)[number];
|
||||
|
||||
export const GROWTH_RULESET_VERSION = 'growth-r1-2026-08-13';
|
||||
|
||||
export interface LifecycleSourceRef {
|
||||
type: 'account' | 'demand_deal' | 'capacity_request' | 'allocation' | 'contract' | 'obligation' | 'activity' | 'commitment';
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface GrowthSignal {
|
||||
code: string;
|
||||
category: GrowthSignalCategory;
|
||||
weight: number;
|
||||
explanation: string;
|
||||
sourceRefs: LifecycleSourceRef[];
|
||||
}
|
||||
|
||||
export interface LifecycleDealSnapshot {
|
||||
id: string;
|
||||
stage: DemandStage;
|
||||
productLine: ProductLine;
|
||||
parentDealId?: string | null;
|
||||
msaExecuted: boolean;
|
||||
lastActivityAt?: Date | null;
|
||||
}
|
||||
|
||||
export interface LifecycleRequestSnapshot {
|
||||
id: string;
|
||||
demandDealId: string;
|
||||
startsAt?: Date | null;
|
||||
endsAt?: Date | null;
|
||||
totalGpuHours?: number | null;
|
||||
}
|
||||
|
||||
export interface LifecycleAllocationSnapshot {
|
||||
id: string;
|
||||
demandDealId?: string | null;
|
||||
status: AllocationStatus;
|
||||
gpuHours: number;
|
||||
startsAt: Date;
|
||||
endsAt: Date;
|
||||
holdExpiresAt?: Date | null;
|
||||
}
|
||||
|
||||
export interface LifecycleContractSnapshot {
|
||||
id: string;
|
||||
status: ContractStatus;
|
||||
expiresAt?: Date | null;
|
||||
isAutoRenew: boolean;
|
||||
noticeDays?: number | null;
|
||||
}
|
||||
|
||||
export interface LifecycleObligationSnapshot {
|
||||
id: string;
|
||||
contractId: string;
|
||||
dueAt: Date;
|
||||
completedAt?: Date | null;
|
||||
}
|
||||
|
||||
export interface LifecycleIdleMatchSnapshot {
|
||||
requestId: string;
|
||||
commitmentId: string;
|
||||
score: number;
|
||||
rationale: string[];
|
||||
}
|
||||
|
||||
export interface CustomerLifecycleInput {
|
||||
accountId: string;
|
||||
deals: LifecycleDealSnapshot[];
|
||||
requests: LifecycleRequestSnapshot[];
|
||||
allocations: LifecycleAllocationSnapshot[];
|
||||
contracts: LifecycleContractSnapshot[];
|
||||
obligations: LifecycleObligationSnapshot[];
|
||||
lastActivityAt?: Date | null;
|
||||
lastActivityId?: string | null;
|
||||
idleMatches?: LifecycleIdleMatchSnapshot[];
|
||||
}
|
||||
|
||||
export interface CustomerLifecycleProjection {
|
||||
relationshipState: CustomerRelationshipState;
|
||||
facets: GrowthFacet[];
|
||||
score: number;
|
||||
scoreByCategory: Record<GrowthSignalCategory, number>;
|
||||
rulesetVersion: string;
|
||||
computedAt: string;
|
||||
signals: GrowthSignal[];
|
||||
blockers: string[];
|
||||
soldCapacityGpuHours: number;
|
||||
heldCapacityGpuHours: number;
|
||||
}
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
export function requestHasCoverage(
|
||||
request: LifecycleRequestSnapshot,
|
||||
allocations: readonly LifecycleAllocationSnapshot[],
|
||||
now: Date,
|
||||
): boolean {
|
||||
return allocations.some((allocation) => {
|
||||
if (allocation.demandDealId !== request.demandDealId || !reservesCapacity(allocation, now)) {
|
||||
return false;
|
||||
}
|
||||
if (request.startsAt && allocation.startsAt > request.startsAt) return false;
|
||||
if (request.endsAt && allocation.endsAt < request.endsAt) return false;
|
||||
if (request.totalGpuHours != null && allocation.gpuHours < request.totalGpuHours) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function evaluateCustomerLifecycle(
|
||||
input: CustomerLifecycleInput,
|
||||
now = new Date(),
|
||||
): CustomerLifecycleProjection {
|
||||
const signals: GrowthSignal[] = [];
|
||||
const blockers: string[] = [];
|
||||
const dealById = new Map(input.deals.map((deal) => [deal.id, deal]));
|
||||
const activeAllocations = input.allocations.filter(
|
||||
(allocation) => allocation.status === 'active' && overlaps(allocation, now),
|
||||
);
|
||||
const liveContracts = input.contracts.filter(
|
||||
(contract) =>
|
||||
contract.status === 'executed' && (!contract.expiresAt || contract.expiresAt > now),
|
||||
);
|
||||
const futureCommitted = input.allocations.some(
|
||||
(allocation) => allocation.status === 'committed' && allocation.endsAt > now,
|
||||
);
|
||||
const wasCustomer =
|
||||
input.allocations.some((allocation) => allocation.status === 'completed' || allocation.endsAt <= now)
|
||||
|| input.contracts.some((contract) =>
|
||||
contract.status === 'expired' || contract.status === 'terminated' || Boolean(contract.expiresAt && contract.expiresAt <= now),
|
||||
)
|
||||
|| input.deals.some((deal) => deal.stage === 'closed_won');
|
||||
|
||||
const relationshipState: CustomerRelationshipState = activeAllocations.length
|
||||
? 'deployed'
|
||||
: liveContracts.length || futureCommitted
|
||||
? 'contracted'
|
||||
: wasCustomer
|
||||
? 'former_customer'
|
||||
: 'prospect';
|
||||
|
||||
const openExpansionDeals = input.deals.filter(
|
||||
(deal) => deal.stage === 'expansion' && isOpenStage(deal.stage),
|
||||
);
|
||||
for (const deal of openExpansionDeals) {
|
||||
addSignal(signals, 'open_expansion_deal', 'expansion', 35, 'An explicit expansion opportunity is open.', [
|
||||
{ type: 'demand_deal', id: deal.id },
|
||||
]);
|
||||
}
|
||||
|
||||
const uncoveredRequests = input.requests.filter(
|
||||
(request) => !requestHasCoverage(request, input.allocations, now),
|
||||
);
|
||||
for (const request of uncoveredRequests) {
|
||||
addSignal(signals, 'uncovered_capacity_request', 'coverage', 30, 'A recorded capacity requirement has no sold or reserved capacity covering its requested shape and window.', [
|
||||
{ type: 'capacity_request', id: request.id },
|
||||
{ type: 'demand_deal', id: request.demandDealId },
|
||||
]);
|
||||
}
|
||||
|
||||
const expansionAnchored = openExpansionDeals.length > 0 || uncoveredRequests.length > 0;
|
||||
const lastActivityAt = latestDate([
|
||||
input.lastActivityAt,
|
||||
...input.deals.map((deal) => deal.lastActivityAt),
|
||||
]);
|
||||
if (
|
||||
expansionAnchored
|
||||
&& (relationshipState === 'deployed' || relationshipState === 'contracted')
|
||||
&& lastActivityAt
|
||||
&& now.getTime() - lastActivityAt.getTime() <= 30 * DAY_MS
|
||||
) {
|
||||
addSignal(signals, 'recent_customer_activity', 'expansion', 15, 'Recent recorded customer activity strengthens an already-evidenced expansion or coverage opportunity.', [
|
||||
{ type: input.lastActivityId ? 'activity' : 'account', id: input.lastActivityId ?? input.accountId },
|
||||
]);
|
||||
}
|
||||
|
||||
for (const match of input.idleMatches ?? []) {
|
||||
addSignal(signals, 'idle_supply_match', 'supply', 10, `Authoritative capacity matching found idle supply: ${match.rationale.join(' ')}`, [
|
||||
{ type: 'capacity_request', id: match.requestId },
|
||||
{ type: 'commitment', id: match.commitmentId },
|
||||
]);
|
||||
}
|
||||
if (uncoveredRequests.length && !(input.idleMatches?.length)) {
|
||||
blockers.push('Customer-to-capacity matching is withheld until an allocation-level compliance decision is available.');
|
||||
}
|
||||
|
||||
for (const contract of input.contracts) {
|
||||
if (!contract.expiresAt) continue;
|
||||
const daysToExpiry = Math.ceil((contract.expiresAt.getTime() - now.getTime()) / DAY_MS);
|
||||
const noticeAt = contract.noticeDays == null
|
||||
? null
|
||||
: new Date(contract.expiresAt.getTime() - contract.noticeDays * DAY_MS);
|
||||
if (contract.isAutoRenew && noticeAt && noticeAt <= now && contract.expiresAt > now) {
|
||||
addSignal(signals, 'renewal_notice_due', 'renewal', 50, 'The contractual notice window is open now; renewal terms need a decision.', [
|
||||
{ type: 'contract', id: contract.id },
|
||||
]);
|
||||
} else if (contract.isAutoRenew && noticeAt && noticeAt.getTime() - now.getTime() <= 30 * DAY_MS && noticeAt > now) {
|
||||
addSignal(signals, 'renewal_notice_soon', 'renewal', 35, 'The contractual renewal notice window opens within 30 days.', [
|
||||
{ type: 'contract', id: contract.id },
|
||||
]);
|
||||
}
|
||||
if (daysToExpiry >= 0 && daysToExpiry <= 120) {
|
||||
addSignal(signals, 'contract_expiring', 'renewal', 25, `Executed customer paper expires in ${daysToExpiry} days.`, [
|
||||
{ type: 'contract', id: contract.id },
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const allocation of activeAllocations) {
|
||||
const futureCoverage = input.allocations.some((candidate) =>
|
||||
candidate.id !== allocation.id
|
||||
&& candidate.demandDealId === allocation.demandDealId
|
||||
&& (candidate.status === 'planned' || candidate.status === 'committed' || candidate.status === 'active')
|
||||
&& reservesCapacity(candidate, now)
|
||||
&& candidate.endsAt > allocation.endsAt
|
||||
&& candidate.startsAt.getTime() <= allocation.endsAt.getTime() + 7 * DAY_MS,
|
||||
);
|
||||
const daysToEnd = Math.ceil((allocation.endsAt.getTime() - now.getTime()) / DAY_MS);
|
||||
if (daysToEnd >= 0 && daysToEnd <= 30 && !futureCoverage) {
|
||||
addSignal(signals, 'allocation_ending_uncovered', 'risk', 30, `Active sold capacity ends in ${daysToEnd} days and no future reservation is recorded.`, [
|
||||
{ type: 'allocation', id: allocation.id },
|
||||
]);
|
||||
}
|
||||
const deal = allocation.demandDealId ? dealById.get(allocation.demandDealId) : undefined;
|
||||
if (deal && !deal.msaExecuted) {
|
||||
addSignal(signals, 'msa_missing_for_active_capacity', 'risk', 25, 'Active sold capacity is linked to a deal whose MSA evidence is not marked executed.', [
|
||||
{ type: 'allocation', id: allocation.id },
|
||||
{ type: 'demand_deal', id: deal.id },
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (activeAllocations.length && !liveContracts.length) {
|
||||
addSignal(signals, 'active_capacity_without_live_contract', 'risk', 40, 'Active sold capacity has no currently executed demand-side contract on the account.', activeAllocations.map((allocation) => ({
|
||||
type: 'allocation' as const,
|
||||
id: allocation.id,
|
||||
})));
|
||||
blockers.push('Confirm governing customer paper before changing or extending sold capacity.');
|
||||
}
|
||||
|
||||
for (const obligation of input.obligations) {
|
||||
if (!obligation.completedAt && obligation.dueAt < now) {
|
||||
addSignal(signals, 'overdue_contract_obligation', 'risk', 25, 'A customer contract obligation is overdue.', [
|
||||
{ type: 'obligation', id: obligation.id },
|
||||
{ type: 'contract', id: obligation.contractId },
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
const staleDays = lastActivityAt
|
||||
? Math.floor((now.getTime() - lastActivityAt.getTime()) / DAY_MS)
|
||||
: null;
|
||||
if (staleDays == null || staleDays >= 90) {
|
||||
addSignal(signals, 'crm_evidence_stale_90', 'risk', 25, staleDays == null
|
||||
? 'CRM evidence is stale: no customer activity is recorded. This does not establish customer disengagement.'
|
||||
: `CRM evidence is stale: no customer activity is recorded in ${staleDays} days. This does not establish customer disengagement.`, [
|
||||
{ type: input.lastActivityId ? 'activity' : 'account', id: input.lastActivityId ?? input.accountId },
|
||||
]);
|
||||
} else if (staleDays >= 60) {
|
||||
addSignal(signals, 'crm_evidence_stale_60', 'risk', 15, `CRM evidence is stale: no customer activity is recorded in ${staleDays} days. This does not establish customer disengagement.`, [
|
||||
{ type: input.lastActivityId ? 'activity' : 'account', id: input.lastActivityId ?? input.accountId },
|
||||
]);
|
||||
}
|
||||
|
||||
signals.sort((left, right) =>
|
||||
right.weight - left.weight
|
||||
|| left.code.localeCompare(right.code)
|
||||
|| sourceKey(left).localeCompare(sourceKey(right)),
|
||||
);
|
||||
blockers.sort();
|
||||
const scoreByCategory = Object.fromEntries(
|
||||
GROWTH_SIGNAL_CATEGORIES.map((category) => [
|
||||
category,
|
||||
Math.min(100, signals.filter((signal) => signal.category === category).reduce((sum, signal) => sum + signal.weight, 0)),
|
||||
]),
|
||||
) as Record<GrowthSignalCategory, number>;
|
||||
const facets: GrowthFacet[] = [];
|
||||
if (scoreByCategory.expansion >= 30 || scoreByCategory.coverage >= 30) facets.push('expansion_candidate');
|
||||
if (signals.some((signal) => signal.category === 'renewal')) facets.push('renewal_due');
|
||||
if (signals.some((signal) => signal.category === 'risk' && !signal.code.startsWith('crm_evidence_stale'))) facets.push('at_risk');
|
||||
if (signals.some((signal) => signal.category === 'supply')) facets.push('idle_supply_match');
|
||||
if (uncoveredRequests.length) facets.push('coverage_gap');
|
||||
if (signals.some((signal) => signal.code.startsWith('crm_evidence_stale'))) facets.push('data_stale');
|
||||
|
||||
return {
|
||||
relationshipState,
|
||||
facets,
|
||||
score: Math.min(100, signals.reduce((sum, signal) => sum + signal.weight, 0)),
|
||||
scoreByCategory,
|
||||
rulesetVersion: GROWTH_RULESET_VERSION,
|
||||
computedAt: now.toISOString(),
|
||||
signals,
|
||||
blockers,
|
||||
soldCapacityGpuHours: input.allocations
|
||||
.filter((allocation) => ['committed', 'active', 'completed'].includes(allocation.status))
|
||||
.reduce((sum, allocation) => sum + allocation.gpuHours, 0),
|
||||
heldCapacityGpuHours: input.allocations
|
||||
.filter((allocation) => allocation.status === 'planned' && reservesCapacity(allocation, now))
|
||||
.reduce((sum, allocation) => sum + allocation.gpuHours, 0),
|
||||
};
|
||||
}
|
||||
|
||||
function reservesCapacity(allocation: LifecycleAllocationSnapshot, now: Date): boolean {
|
||||
if (allocation.status === 'released' || allocation.status === 'completed') return false;
|
||||
return allocation.status !== 'planned' || !allocation.holdExpiresAt || allocation.holdExpiresAt > now;
|
||||
}
|
||||
|
||||
function overlaps(allocation: LifecycleAllocationSnapshot, now: Date): boolean {
|
||||
return allocation.startsAt <= now && allocation.endsAt > now;
|
||||
}
|
||||
|
||||
function isOpenStage(stage: DemandStage): boolean {
|
||||
return (DEMAND_OPEN_STAGES as readonly DemandStage[]).includes(stage);
|
||||
}
|
||||
|
||||
function latestDate(values: readonly (Date | null | undefined)[]): Date | null {
|
||||
return values.reduce<Date | null>((latest, value) =>
|
||||
value && (!latest || value > latest) ? value : latest, null);
|
||||
}
|
||||
|
||||
function addSignal(
|
||||
target: GrowthSignal[],
|
||||
code: string,
|
||||
category: GrowthSignalCategory,
|
||||
weight: number,
|
||||
explanation: string,
|
||||
sourceRefs: LifecycleSourceRef[],
|
||||
): void {
|
||||
target.push({ code, category, weight, explanation, sourceRefs });
|
||||
}
|
||||
|
||||
function sourceKey(signal: GrowthSignal): string {
|
||||
return signal.sourceRefs.map((ref) => `${ref.type}:${ref.id}`).join('|');
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
evaluateCustomerLifecycle,
|
||||
type CustomerLifecycleInput,
|
||||
} from '../src/lifecycle';
|
||||
|
||||
const NOW = new Date('2026-08-13T12:00:00.000Z');
|
||||
const ACCOUNT_ID = '10000000-0000-4000-8000-000000000001';
|
||||
const DEAL_ID = '20000000-0000-4000-8000-000000000001';
|
||||
|
||||
function input(overrides: Partial<CustomerLifecycleInput> = {}): CustomerLifecycleInput {
|
||||
return {
|
||||
accountId: ACCOUNT_ID,
|
||||
deals: [],
|
||||
requests: [],
|
||||
allocations: [],
|
||||
contracts: [],
|
||||
obligations: [],
|
||||
lastActivityAt: NOW,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('customer lifecycle rules', () => {
|
||||
it('uses allocation state and a fixed clock rather than treating a hold as deployment', () => {
|
||||
const base = {
|
||||
id: '30000000-0000-4000-8000-000000000001',
|
||||
demandDealId: DEAL_ID,
|
||||
gpuHours: 1_000,
|
||||
startsAt: new Date('2026-08-01T00:00:00.000Z'),
|
||||
endsAt: new Date('2026-09-01T00:00:00.000Z'),
|
||||
};
|
||||
const held = evaluateCustomerLifecycle(input({ allocations: [{ ...base, status: 'planned', holdExpiresAt: new Date('2026-08-14T00:00:00.000Z') }] }), NOW);
|
||||
const deployed = evaluateCustomerLifecycle(input({ allocations: [{ ...base, status: 'active' }] }), NOW);
|
||||
|
||||
assert.equal(held.relationshipState, 'prospect');
|
||||
assert.equal(held.heldCapacityGpuHours, 1_000);
|
||||
assert.equal(deployed.relationshipState, 'deployed');
|
||||
});
|
||||
|
||||
it('suppresses ending-capacity risk when a future reservation covers the same deal', () => {
|
||||
const current = {
|
||||
id: '30000000-0000-4000-8000-000000000001',
|
||||
demandDealId: DEAL_ID,
|
||||
status: 'active' as const,
|
||||
gpuHours: 1_000,
|
||||
startsAt: new Date('2026-07-01T00:00:00.000Z'),
|
||||
endsAt: new Date('2026-08-20T00:00:00.000Z'),
|
||||
};
|
||||
const future = {
|
||||
...current,
|
||||
id: '30000000-0000-4000-8000-000000000002',
|
||||
status: 'committed' as const,
|
||||
startsAt: new Date('2026-08-20T00:00:00.000Z'),
|
||||
endsAt: new Date('2026-10-01T00:00:00.000Z'),
|
||||
};
|
||||
const result = evaluateCustomerLifecycle(input({ allocations: [current, future] }), NOW);
|
||||
|
||||
assert.equal(result.signals.some((signal) => signal.code === 'allocation_ending_uncovered'), false);
|
||||
});
|
||||
|
||||
it('describes missing activity as stale CRM evidence, never customer disengagement', () => {
|
||||
const result = evaluateCustomerLifecycle(input({ lastActivityAt: null }), NOW);
|
||||
const stale = result.signals.find((signal) => signal.code === 'crm_evidence_stale_90');
|
||||
|
||||
assert.match(stale?.explanation ?? '', /CRM evidence is stale/);
|
||||
assert.match(stale?.explanation ?? '', /does not establish customer disengagement/);
|
||||
});
|
||||
|
||||
it('orders explainable weighted signals deterministically', () => {
|
||||
const result = evaluateCustomerLifecycle(input({
|
||||
deals: [{ id: DEAL_ID, stage: 'expansion', productLine: 'compute_reserved', msaExecuted: true, lastActivityAt: NOW }],
|
||||
requests: [{ id: '40000000-0000-4000-8000-000000000001', demandDealId: DEAL_ID, totalGpuHours: 500 }],
|
||||
contracts: [{ id: '50000000-0000-4000-8000-000000000001', status: 'executed', isAutoRenew: true, noticeDays: 30, expiresAt: new Date('2026-09-01T00:00:00.000Z') }],
|
||||
}), NOW);
|
||||
|
||||
assert.deepEqual(result.signals.map((signal) => signal.weight), [...result.signals.map((signal) => signal.weight)].sort((a, b) => b - a));
|
||||
assert.ok(result.signals.every((signal) => signal.explanation && signal.sourceRefs.length));
|
||||
assert.equal(result.rulesetVersion, 'growth-r1-2026-08-13');
|
||||
assert.equal(result.computedAt, NOW.toISOString());
|
||||
});
|
||||
|
||||
it('labels allocation volume only as sold or held capacity', () => {
|
||||
const result = evaluateCustomerLifecycle(input(), NOW);
|
||||
const serialized = JSON.stringify(result);
|
||||
assert.doesNotMatch(serialized, /workload utilization|customer utilization/i);
|
||||
assert.ok('soldCapacityGpuHours' in result);
|
||||
assert.ok('heldCapacityGpuHours' in result);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user