Skip to content

@repo/pricing — extract pricing resolution into a connection-aware package

Status: Draft spec (2026-07-30). Sibling to simplify-cross-erp-pricing-config.md — this doc is the packaging/architecture half of that plan’s Phase 4 (make pricing_resolution_strategy the real runtime dispatch key). Do not read them apart.

Pricing resolution is connection-shaped (each ERP prices differently) but the logic that encodes those per-ERP decisions is scattered across apps/webapp, apps/ts-temporal-worker, packages/eval, and packages/integrations, with no single owner. Three concrete consequences, all verified this session:

  1. P21 has no pricing safety net, and the failure is silent. P21’s inv_mast exposes no list_price column (verified live: columns are price1..price10, cost fields, default_price_family_uid, round_prices). P21 prices are 10 numbered levels stored at the location level (inv_loc; 383/500 sampled rows carry non-zero price1/price6), selected per-customer by source_price_code (e.g. 106 → “Price 6”), method 211=multiplier / 214=fixed. list_price = 0 is therefore structural and expected, not missing data. But calculateCustomerPrice’s last fallback rung is list_price — permanently 0 for P21 — so an unresolvable source_price_code yields null, rendered as “not in catalog”/“Missing”, indistinguishable from a genuinely un-catalogued item. WhereFour has a scraped-tier fallback; P21 has nothing. (The ~23% of P21 items that are genuinely 0 — special/legacy/inactive — are correctly unpriced; the defect is the priced items whose customer tier can’t resolve.)

  2. The per-ERP decision (pricing_resolution_strategy) is applied inconsistently. The three server-side auto-pricing paths (auto-validate.ts:810, the Temporal persist-auto-priced-matches.ts:270, and their shared resolveAndReviewPrices) are fully connection-aware and correctly turn the P21 unknown-source case into null/review, never $0. But the interactive catalog picker and several low-level display resolvers diverge (table below). “Forgot to pass dispatchMode” is expressible today because dispatch is a hand-threaded argument, not a property of the connection.

  3. The cross-package plumbing has already drifted. The worker consumes webapp pricing via a #webapp/* alias that maps to hand-written .d.ts stubs at lint time (tsconfig.lint.json) and to the real modules at build time (tsconfig.json). The stub pricing-calculator.d.ts declares 3 functions; the runtime module exports 14 (verified by count). Lint passes against a shadow that no longer matches runtime. This is the acknowledged-but-deferred symptom — persist-auto-priced-matches.ts:73 literally names “the eventual move into a shared @repo/* package.”

Verified inconsistencies (from the 4-dimension audit, ranked)

Section titled “Verified inconsistencies (from the 4-dimension audit, ranked)”
Sev Location Divergence
HIGH apps/ts-temporal-worker/src/webapp-types/utils/pricing-calculator.d.ts Hand-written lint stub declares 3 of the module’s 14 runtime exports; omits resolveCustomerPriceWithCache, fetchEffectivePriceMap, getPricingDispatchModeForConnection, connectionHasPricingCoverage, detectOrderMinimumQty, and the ResolvedPrice/ResolvePriceInput/PriceSource types. Lint typechecks a shadow of runtime.
HIGH apps/webapp/src/services/erp-query/typesense-query-service.ts:78 price: doc.price1 || doc.list_price || 0 — connection-unaware, ignores source_price_code/multiplier, and (unlike the picker) has no downstream override to correct it. Dormant (no live caller) but unmasked.
MED apps/webapp/src/pages/api/erp/parts/index.ts:484 Typesense layer did resolvePickerPrice(...) ?? result.price, so a P21 unknown-source line silently showed the raw price1/$0 scalar, while the cross-ref (:633) and fuzzy (:743) layers in the same handler surface null. ← fixed in this change, flag-gated (pricing_picker_hide_unresolved_price_v1).
MED apps/webapp/src/pages/api/erp/parts/index.ts:386 resolvePickerPrice called resolveCustomerPriceWithCache without dispatchMode → always legacy, never honored erp_resolves. ← fixed in this change (resolves + passes dispatchMode).
MED apps/webapp/src/services/search/typesense-search-service.ts:2002 mapProductDocument sets SearchResult.price = pricing.price1 || pricing.list_price || 0 — the exact scalar that leaked at parts/index.ts:484, and leaks anywhere reading SearchResult.price directly.
MED apps/webapp/src/components/orders/review-v2/ReviewCenterPane.tsx:1466 PriceDeviationWarning early-returns on erpPrice == null || erpPrice <= 0; because P21 resolvedErpPrice is structurally null when the tier is unresolved, the PO-vs-tier integrity check is permanently dark for P21.
MED apps/webapp/src/pages/api/erp/parts/index.ts:796 bypassCache “Force direct ERP search” returns connector output verbatim — no calculator, no customer pricing (E4 SOAP = $0). Checkbox-gated legacy route.
MED apps/ts-temporal-worker/src/webapp-types/services/pricing/auto-price-resolve.d.ts:48 Second drifted stub: ResolvedItem missing orderMinimumQty.
LOW 3× copies doc→{price1..price10,list_price} flattening (typesense-search-service.ts:1987, typesense-query-service.ts:79, fuzzy-matching.ts:291/460), none connection-aware; || 0 default collides with P21’s “0 = unset”.
LOW 3× copies customer→ErpCustomerPricingInfo conversion (customers/[id]/pricing.ts:48, typesense-search-service.ts:2139, auto-validation-runner.ts:525).
LOW packages/eval/src/runners/auto-validation-runner.ts:454 Omits dispatchMode + tierByName, so eval under-represents erp_resolves/WhereFour tier-bridge vs prod.
LOW apps/webapp/src/utils/pricing-calculator.ts:633 resolveCustomerPrice (async single-line) has zero callers — dead, delete on extraction.

A leaf package depending only on @repo/db, @repo/integrations (for the existing PRICING_METHOD_MAP/SOURCE_PRICE_MAP — already the one correctly-shared pricing symbol), and the feature-flag client. No apps/* imports (that circular edge is what the current #webapp/* stub hack exists to avoid).

From Symbols
apps/webapp/src/utils/pricing-calculator.ts calculateCustomerPrice, resolveCustomerPriceWithCache, fetchEffectivePriceMap, detectOrderMinimumQty, getPricingDispatchModeForConnection, connectionHasPricingCoverage, resolvePricingDispatchMode; types ResolvedPrice/ResolvePriceInput/PriceSource/ErpProductRecord/ErpCustomerPricingInfo/ProductPriceInfo/PricingDispatchMode. Delete dead resolveCustomerPrice.
apps/webapp/src/services/pricing/auto-price-resolve.ts resolveAndReviewPrices, resolveWorkerPricingDispatchMode, ResolvedItem (add orderMinimumQty so the drifted stub is deleted, not re-copied).
apps/webapp/src/services/erp-query/pricing-query-service.ts PricingQueryService interface, EffectivePriceRow, MAX_TIER_ROWS_PER_ITEM, getPricingQueryService() — cleanest seam (interface-segregated, DI-friendly).
apps/webapp/src/services/erp-query/postgres-effective-price-service.ts PostgresEffectivePriceService, TierPriceIndex, normalizeTierName, getCustomerTierPricesByName (the WhereFour tier-bridge; Phase 4 deletes it later — it moves first).
NEW mappers (collapse the 3× + 3× copies) mapDocToPricing() and mapCustomerToPricingInfo().

PRICING_METHOD_MAP/SOURCE_PRICE_MAP stay in @repo/integrations; @repo/pricing depends on it. (It’s already the model for correct sharing.)

The centralizing abstraction: PricingStrategy

Section titled “The centralizing abstraction: PricingStrategy”

Keyed on the pricing_resolution_strategy enum that already exists at erp_pipeline/contracts/pricing.py:18 and is never read in the webapp today — this wires it.

export interface PricingStrategy {
// resolve one line. ASYNC and failure-aware on purpose: the LOCALLY_RESOLVES
// and HYBRID strategies do DB / live-ERP I/O, so a sync `| null` return would
// both hide that I/O behind a sync API and conflate "no price for this
// customer/item" with "the pricing backend errored" — which must render
// differently (blank/suggest vs. a review/retry state).
// status: 'resolved' -> price is authoritative
// status: 'no_price' -> resolved successfully, this customer/item has none
// status: 'error' -> lookup failed (caller decides: fall back, flag, retry)
resolve(input: ResolvePriceInput, ctx: PricingContext): Promise<PricingResult>;
}
export type PricingResult =
| { status: 'resolved'; price: number; source: PriceSource }
| { status: 'no_price' }
| { status: 'error'; reason: string };
// single dispatch entry point — replaces every hand-threaded dispatchMode arg
export function getPricingStrategyForConnection(connection: Connection): PricingStrategy;

Keep the feature-flag client behind a package-safe boundary: @repo/pricing must depend on @repo/feature-flags (or take a flag-getter via DI), never the webapp’s @/lib/feature-flags/* aliases — that is what keeps the package worker-importable (and is exactly the dependency that blocks moving the Postgres impl until the org-flag helper is promoted, per the expand step).

  • ErpResolvesStrategy (P21): numbered source_price_code → price{N}, method 211/214. Never touches erp_effective_prices (P21 writes zero gold rows by design). list_price = 0 → null, no fallback (correct — but the caller decides how to render null, uniformly).
  • LocallyResolvesStrategy (WhereFour/VMS): the only strategy that reads erp_effective_prices, plus source_label/confidence (Phase 2) and the tier-name bridge until Phase 4 deletes it.
  • HybridStrategy (Acumatica/Eclipse E4): live per-request resolve, optional cache-only gold snapshot at low precedence.

Every call site — the picker’s resolvePickerPrice, auto-validate, the worker activity, the eval runner — calls strategy.resolve(). That single entry point is what kills the divergences structurally: the picker can no longer “forget” dispatchMode, and the naive ?? result.price fallback becomes one uniform “strategy returned null” contract across all three picker layers.

Two different kinds of “reversible” are in play here — don’t conflate them:

  • Behavioral rollout is runtime-flag-gated and config-reversible. Only the changes that alter the resolved price at runtime (routing call sites through the strategy, the picker’s null-vs-scalar output) ride behind pricing_strategy_dispatch_v1 / pricing_picker_* and can be flipped off per-connection with no deploy.
  • Code moves are NOT flag-gated. Creating @repo/pricing, moving files, deleting the worker stubs / #webapp alias / old re-export shims are ordinary code changes — reversible only by reverting the PR / redeploying, never by a flag flip. Steps below are labelled so operators don’t assume the extraction can be rolled back through configuration alone.
  1. Expand. Create @repo/pricing; move the two erp-query pricing services first (cleanest seam, only @repo/db + flags). Re-export from old webapp paths as barrel shims. No behavior change, no caller edits.
  2. Migrate the resolvers + kill the stubs. Move pricing-calculator.ts + auto-price-resolve.ts + the new mappers into the package, still re-exported from old webapp paths. Point the worker at @repo/pricing directly and delete both hand-written webapp-types stubs + the #webapp pricing alias — this removes the two HIGH drift findings. (This is why the stub fix could not ship standalone as a “quick fix”: pricing-calculator.ts:5 imports the webapp’s @/lib/feature-flags/connection-flag alias, which the worker’s tsconfig.lint doesn’t define; only package-relative imports resolve cleanly. It’s clean here, not before.)
  3. Introduce PricingStrategy + getPricingStrategyForConnection wrapping the existing resolvers. Route the four call sites through it one at a time, behind the existing pricing_strategy_dispatch_v1 flag, per connection. P21 ERP_RESOLVES and WhereFour LOCALLY_RESOLVES keep exact current behavior while the flag is off.
  4. Fix the readers. Route parts/index.ts and the display resolvers through the strategy (kills the remaining MED/LOW readers); swap the 3× + 3× copies for mapDocToPricing/mapCustomerToPricingInfo.
  5. Contract. Delete the dead resolveCustomerPrice and the old webapp re-export shims once every import points at @repo/pricing. Do not delete tier_name_bridge here — that stays gated on Phase 4 after source_label has been live one full materialization cycle.
  • Routing live P21 and WhereFour through a new strategy dispatch changes runtime order for both at once — must stay behind pricing_strategy_dispatch_v1 / pricing_picker_unified_v1, flipped per-connection (exactly the Phase 4 staging).
  • Keep @repo/pricing leaf-level (deps: @repo/db + @repo/integrations + flags) — a webapp ↔ pricing circular dep is the failure mode; invert via the already-injectable pricingService param where needed.
  • Removing the worker’s #webapp esbuild alias must keep the Temporal bundle self-contained — verify the worker build still bundles @repo/pricing.
  • Typesense/contract pricing is still flat price1..price10 (design-doc Phase 3 not yet contracted). Have @repo/pricing consume a tiers dict via mapDocToPricing so it’s Phase-3 ready, but do not hard-couple the package to the flat field names.

Already landed alongside this spec (the flag-gated quick fixes)

Section titled “Already landed alongside this spec (the flag-gated quick fixes)”
  • Catalog picker (parts/index.ts): now passes dispatchMode to resolveCustomerPriceWithCache, and behind pricing_picker_hide_unresolved_price_v1 (default off) surfaces null for an unresolved P21 line instead of the misleading raw price1 || list_price || 0 scalar — matching the cross-ref/fuzzy layers. Tests added (parts/index.test.ts, both flag states). Flag off = byte-for-byte prior behavior.
  • Stub drift: intentionally not shipped standalone — folded into migration step 2 above, where deleting the stubs is clean (see the parenthetical there).
  1. ✅ Quick fixes (picker dispatchMode + flag-gated null; this spec).
  2. Expand step (move erp-query services + shims) — smallest safe PR, proves the seam.
  3. Migrate resolvers + delete stubs (step 2) — removes both HIGH findings.
  4. PricingStrategy interface + per-call-site routing behind the flag (steps 3–4).
  5. Contract (step 5) — after the flag has ridden a full cycle on a P21 + a WhereFour connection.