Pricing calculation audit trail — Phase 1 capture

Freezes a typed, reproducible trace of the pricing engine's output at every SAVE, so any displayed TCV/ARR can be explained exactly — without an engineer reconstructing the math by hand.

PRdealops#5709 Author@mileszim Basemain Files19 ±+954 / −6 Phase1 of 3 Flagoff by default SurfaceCapture-only

What it adds
A new PricingCalculationTrace row written on every flag-enabled quote save, with a typed TraceNode tree pinned to engineVersion + inputHash.
What it changes
The engine's peLogger seam grows an optional event() method. A TraceCollector can be injected at the two SAVE call sites in place of the default logger.
What it preserves
Flag-off behavior is byte-identical: no collector, no event() calls, no DB write. Capture is wrapped in try/catch so a failure never breaks a save.
Engine / summary
Collector / logger seam
Trace tree / persistence
Future surface (Phase 2/3)

1. Why this exists

Sales and finance regularly ask "why does this quote's TCV/ARR show this number? I think it's off." Today, answering means an engineer reruns the pricing engine in their head against a snapshot of inputs and a snapshot of engine code that may have already moved on. The answer is slow, manual, and not reproducible.

This PR is Phase 1 of rfcs/2026-05-29-pricing-calculation-audit-trail.md. It builds the foundation — the collector, the tree builder, the persistence model — and wires it into the SAVE milestone on both quote-creation paths. There is no read API and no UI yet; that's Phase 2.

The ask
"Explain this TCV." Today: an engineer. With this PR: a stored row keyed to the quote.
The guarantee
The trace's top-line numbers are taken from the same summary that is stored and displayed. They cannot diverge.
The horizon
Even after the engine changes, the frozen engineVersion + effectiveInput let the calculation be reproduced.

2. How a SAVE flows, end to end

Use the carousel to step through one quote save with the flag enabled. The engine path is unchanged; what's new is the collector injected into peLogger, and the best-effort write at the end.

3. The two SAVE call sites

Wiring is identical on both paths. Build a collector iff the flag is on for the org; pass it through the engine; capture against the final summary after persistence.

PricingQuoteService.createPricingQuote

Fires when a quote is created from scratch.

const traceEnabled = await
  isPricingCalculationTraceEnabledForOrg(
    { organizationId, userId });
const traceCollector = traceEnabled
  ? new TraceCollector() : undefined;

const pricingEngineSummary =
  await PricingEngineSummary.calculateSummary(
    calculationInput,
    traceCollector
      ? { peLogger: traceCollector }
      : undefined,
  );

// …amendment override, persist quote…

if (traceCollector) {
  await captureCalculationTrace({
    organizationId,
    pricingQuoteId: createdQuote.id,
    trigger: 'SAVE',
    effectiveInput: inputWithDefaults,
    summary: pricingEngineSummary,
    collector: traceCollector,
  });
}
pricingQuote.update → refreshOutput

Fires on the recalc branch of update.

const updateTraceEnabled = await
  isPricingCalculationTraceEnabledForOrg({
    organizationId: ctx.organization.id,
    userId: ctx.user.id,
  });
const updateTraceCollector = updateTraceEnabled
  ? new TraceCollector() : undefined;

const finalPricingEngineSummary =
  await PricingEngineSummary.calculateSummary(
    summaryInputUpdate,
    updateTraceCollector
      ? { peLogger: updateTraceCollector }
      : undefined,
  );

// …amendment override, persist quote…

if (updateTraceCollector) {
  await captureCalculationTrace({
    organizationId: ctx.organization.id,
    pricingQuoteId: id,
    trigger: 'SAVE',
    effectiveInput: finalMergedInput,
    summary: finalPricingEngineSummary,
    collector: updateTraceCollector,
  });
}

4. The peLogger seam, before and after

The engine already accepts a peLogger on FormulaEvaluateAttr for Datadog logging. This PR adds an optional event() sibling. PricingEngineLogger doesn't implement it; TraceCollector does. Engine call sites guard with peLogger?.event?.(…).

Before
peLogger?: {
  log: (message: string,
    meta?: Record<string, unknown>) => void;
  invocationId: string;
};

One method. Logging only.

After
peLogger?: {
  log: (message: string,
    meta?: Record<string, unknown>) => void;
  invocationId: string;
  // NEW — optional, guarded at call sites
  event?: (event: TraceEventInput) => void;
};

Plus an exported structural alias:

export type PricingEnginePeLogger =
  NonNullable<FormulaEvaluateAttr['peLogger']>;

The new calculateSummary signature accepts an injected logger by this structural type — so neither concrete implementation is coupled to the other.

static async calculateSummary(
  input: PricingEngineSummaryInput,
  options?: { peLogger?: PricingEnginePeLogger },
): Promise<PricingEngineSummaryOutput> {
  // …
  const peLogger = options?.peLogger ?? new PricingEngineLogger();
  // …
}

5. Building the tree

The tree is built from the final summary (post amendment override), not from the live event stream. That is the entire reason the trace's TCV/ARR can never disagree with the displayed numbers — they are literally the same values, threaded into a structured shape.

Any line-item events the collector picked up during the run are merged in as deeper detail, attached by parentKey === 'product:<id>'. Unmatched events attach at the root.

Shape produced

└─ deal_total · "Deal total" · output = TCV.all customer
├─ tcv · "Total Contract Value (TCV)" customer
├─ tcv · "Year 1"
└─ tcv · "Year 2"
├─ arr · "ARR at scale" customer
├─ net_new_arr · "Net new ARR" internal
├─ deal_discount · "Deal discount" customer
└─ product_revenue · "Card Issuing" customer
├─ variable_eval · "takeRate" internal
└─ tier_match · "Tier 3" ← from collected events

Visibility

Every node carries a visibility flag — customer for price × volume × term × discount facts, internal for cost, margin, and engine fallbacks. The future explain UI will gate on this.

6. Reproducibility: inputHash and stableStringify

The trace row is keyed on (pricingQuoteId, inputHash, trigger) for dedup. The hash has to survive a Postgres round-trip — an in-memory input and the same input loaded back from JSONB must produce the same hash. JSON.stringify alone is not enough.

Invariant 1 — Key order
Object keys are sorted before serialization, so reordering properties cannot change the hash.
Invariant 2 — Undefined
undefined properties are omitted, not emitted as null. Prisma strips them on write, so memory and DB shapes must match.
Invariant 3 — Dates
Objects with toJSON are serialized via it, so a Date becomes its ISO string — not "{}".
// stableStringify({ b: 1, a: 2 }) === stableStringify({ a: 2, b: 1 })
// stableStringify({ a: 1, b: undefined }) === '{"a":1}'
// stableStringify(new Date(...)) === '"2026-05-29T00:00:00.000Z"'

7. The persistence model

ColumnTypeRole
iduuidPK
organizationIdFK → OrganizationTenant scope; sandbox extractor auto-derives because of this direct FK (no registry.ts entry).
pricingQuoteIdFK → PricingQuoteWhat this trace explains.
triggerenum TraceTriggerSAVE | APPROVAL_SUBMIT | CRM_PUSH — only SAVE is emitted today.
capturedAttimestampfindLatestForQuote orders by this desc.
engineVersiontextGit SHA (RENDER_GIT_COMMIT / GITHUB_SHA / local-dev).
inputHashtextSHA-256 of stableStringify(effectiveInput).
effectiveInputjsonbThe augmented input actually fed to the engine.
tcv, arrjsonbDenormalized top-line for list views and drift checks.
invocationIdtextSame UUID the collector tagged Datadog logs with — Datadog ↔ trace cross-link.
tracejsonbThe full TraceNode tree.
Indexes
(pricingQuoteId, capturedAt) for latest-per-quote lookup, (organizationId) for tenant scans.
Unique constraint
(pricingQuoteId, inputHash, trigger) — identical re-saves upsert rather than pile up.
FKs
Two: Organization and PricingQuote, both ON DELETE RESTRICT.

8. Safety — failure modes and the flag

Flag off (default)
The collector is never constructed. calculateSummary runs with a plain PricingEngineLogger. No DB write. The hot path is byte-identical to before this PR.
Flag on, trace fails
captureCalculationTrace wraps everything in try/catch. A failure calls errorNotificationService.logWarning and returns. The quote save proceeds. Matches the existing tryEmit philosophy on these paths.
Flag on, save fails
The trace is captured after the quote is persisted. A quote-create failure means no trace. A trace failure does not undo the quote.
Rollout note from the PR: traces cannot be backfilled. Flipping the flag on for an org earlier banks more history. The flag list is currently empty — turn it on per-org by editing the enabledOrgs set in packages/feature-flags/flags.ts.

9. What it doesn't change

10. Follow-ups (Phase 2 / 3)

Phase 2 — more milestones
Wire APPROVAL_SUBMIT and CRM_PUSH. The helper and enum already support both.
Phase 2 — deeper events
Emit line-item event()s from inside the engine (tier match, proration, fallback, list-price pick). The collect/merge path is built and tested; needs per-decision dedup since getRevenue runs many times per product.
Phase 3 — read side
explainQuote tRPC + internal/customer explain UI. Visibility flag on each node gates what customer mode renders.
Validation
Shadow-validation rollout: assert recomputed-from-trace top-line equals persisted output.tcv/arr.

11. Open questions for review