Files
karti 74e37f3e76 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>
2026-08-13 03:50:18 -07:00

92 lines
3.9 KiB
TypeScript

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