@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.
Problem
Section titled “Problem”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:
-
P21 has no pricing safety net, and the failure is silent. P21’s
inv_mastexposes nolist_pricecolumn (verified live: columns areprice1..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-zeroprice1/price6), selected per-customer bysource_price_code(e.g. 106 → “Price 6”), method211=multiplier /214=fixed.list_price = 0is therefore structural and expected, not missing data. ButcalculateCustomerPrice’s last fallback rung islist_price— permanently0for P21 — so an unresolvablesource_price_codeyieldsnull, 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 genuinely0— special/legacy/inactive — are correctly unpriced; the defect is the priced items whose customer tier can’t resolve.) -
The per-ERP decision (
pricing_resolution_strategy) is applied inconsistently. The three server-side auto-pricing paths (auto-validate.ts:810, the Temporalpersist-auto-priced-matches.ts:270, and their sharedresolveAndReviewPrices) are fully connection-aware and correctly turn the P21 unknown-source case intonull/review, never$0. But the interactive catalog picker and several low-level display resolvers diverge (table below). “Forgot to passdispatchMode” is expressible today because dispatch is a hand-threaded argument, not a property of the connection. -
The cross-package plumbing has already drifted. The worker consumes webapp pricing via a
#webapp/*alias that maps to hand-written.d.tsstubs at lint time (tsconfig.lint.json) and to the real modules at build time (tsconfig.json). The stubpricing-calculator.d.tsdeclares 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:73literally 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. |
Proposed package: @repo/pricing
Section titled “Proposed package: @repo/pricing”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).
What moves
Section titled “What moves”| 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 argexport 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): numberedsource_price_code→price{N}, method 211/214. Never toucheserp_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 readserp_effective_prices, plussource_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.
Migration — expand / migrate / contract
Section titled “Migration — expand / migrate / contract”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 /#webappalias / 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.
- Expand. Create
@repo/pricing; move the twoerp-querypricing services first (cleanest seam, only@repo/db+ flags). Re-export from old webapp paths as barrel shims. No behavior change, no caller edits. - 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/pricingdirectly and delete both hand-writtenwebapp-typesstubs + the#webapppricing 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:5imports the webapp’s@/lib/feature-flags/connection-flagalias, which the worker’stsconfig.lintdoesn’t define; only package-relative imports resolve cleanly. It’s clean here, not before.) - Introduce
PricingStrategy+getPricingStrategyForConnectionwrapping the existing resolvers. Route the four call sites through it one at a time, behind the existingpricing_strategy_dispatch_v1flag, per connection. P21ERP_RESOLVESand WhereFourLOCALLY_RESOLVESkeep exact current behavior while the flag is off. - Fix the readers. Route
parts/index.tsand the display resolvers through the strategy (kills the remaining MED/LOW readers); swap the 3× + 3× copies formapDocToPricing/mapCustomerToPricingInfo. - Contract. Delete the dead
resolveCustomerPriceand the old webapp re-export shims once every import points at@repo/pricing. Do not deletetier_name_bridgehere — that stays gated on Phase 4 aftersource_labelhas 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/pricingleaf-level (deps:@repo/db+@repo/integrations+ flags) — awebapp ↔ pricingcircular dep is the failure mode; invert via the already-injectablepricingServiceparam where needed. - Removing the worker’s
#webappesbuild 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/pricingconsume atiersdict viamapDocToPricingso 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 passesdispatchModetoresolveCustomerPriceWithCache, and behindpricing_picker_hide_unresolved_price_v1(default off) surfacesnullfor an unresolved P21 line instead of the misleading rawprice1 || list_price || 0scalar — 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).
Sequencing
Section titled “Sequencing”- ✅ Quick fixes (picker
dispatchMode+ flag-gated null; this spec). - Expand step (move
erp-queryservices + shims) — smallest safe PR, proves the seam. - Migrate resolvers + delete stubs (step 2) — removes both HIGH findings.
PricingStrategyinterface + per-call-site routing behind the flag (steps 3–4).- Contract (step 5) — after the flag has ridden a full cycle on a P21 + a WhereFour connection.