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.
PricingCalculationTrace row written on every flag-enabled quote save, with a typed TraceNode tree pinned to engineVersion + inputHash.
peLogger seam grows an optional event() method. A TraceCollector can be injected at the two SAVE call sites in place of the default logger.
event() calls, no DB write. Capture is wrapped in try/catch so a failure never breaks a save.
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.
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.
isPricingCalculationTraceEnabled is on for this org + user. If off, no collector is constructed and the rest of this diagram never happens.
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.
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,
});
}
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?.(…).
peLogger?: {
log: (message: string,
meta?: Record<string, unknown>) => void;
invocationId: string;
};
One method. Logging only.
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
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.
undefined properties are omitted, not emitted as null. Prisma strips them on write, so memory and DB shapes must match.
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
| Column | Type | Role |
|---|---|---|
id | uuid | PK |
organizationId | FK → Organization | Tenant scope; sandbox extractor auto-derives because of this direct FK (no registry.ts entry). |
pricingQuoteId | FK → PricingQuote | What this trace explains. |
trigger | enum TraceTrigger | SAVE | APPROVAL_SUBMIT | CRM_PUSH — only SAVE is emitted today. |
capturedAt | timestamp | findLatestForQuote orders by this desc. |
engineVersion | text | Git SHA (RENDER_GIT_COMMIT / GITHUB_SHA / local-dev). |
inputHash | text | SHA-256 of stableStringify(effectiveInput). |
effectiveInput | jsonb | The augmented input actually fed to the engine. |
tcv, arr | jsonb | Denormalized top-line for list views and drift checks. |
invocationId | text | Same UUID the collector tagged Datadog logs with — Datadog ↔ trace cross-link. |
trace | jsonb | The full TraceNode tree. |
(pricingQuoteId, capturedAt) for latest-per-quote lookup, (organizationId) for tenant scans.
(pricingQuoteId, inputHash, trigger) — identical re-saves upsert rather than pile up.
Organization and PricingQuote, both ON DELETE RESTRICT.
8. Safety — failure modes and the flag
calculateSummary runs with a plain PricingEngineLogger. No DB write. The hot path is byte-identical to before this PR.
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.
enabledOrgs set in packages/feature-flags/flags.ts.
9. What it doesn't change
- The pricing engine's calculation logic. Not a single arithmetic line is touched.
- The Datadog logging contract.
TraceCollector.log()forwards with the same fieldsPricingEngineLoggeruses. - The
PricingQuoterow, itsinput, itsoutput. The trace lives in a sibling table. - Any read path. There's no tRPC route, no UI, no API surface — this PR is capture-only.
- Behavior for any org until that org id is added to the flag's
enabledOrgsset. - Approval submit and CRM push milestones. The enum supports them; the wiring lands in Phase 2.
10. Follow-ups (Phase 2 / 3)
APPROVAL_SUBMIT and CRM_PUSH. The helper and enum already support both.
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.
explainQuote tRPC + internal/customer explain UI. Visibility flag on each node gates what customer mode renders.
output.tcv/arr.
11. Open questions for review
- Should the upsert key include
engineVersion? Right now an engine bump + identical input + same trigger overwrites the prior row. Acceptable, but worth a deliberate yes/no. findLatestForQuoteorders bycapturedAt, nottrigger. OnceCRM_PUSHexists, "latest" may mean different things to different consumers.currentEngineVersion()falls back to'local-dev'. Traces captured locally and shipped to a prod-like DB are indistinguishable — fine in practice, just worth knowing.