@classytic/sales-analytics
v0.1.1
Published
SALES binding over the @classytic/facts fact-projection kernel — the salesFact/salesDaily projection spec (dimensions, the recognized-revenue measure model: qty + basis-explicit booked/fulfilled/returned/cancelled money, quality markers, 7-field rollup gr
Readme
@classytic/sales-analytics
The SALES binding over @classytic/facts — the erp-plan §17.3
reference implementation of a fact-projection domain binding. The kernel
owns the machinery (generation manifest + verified cutover, the idempotent
§4.3 projection transaction, rollup rebuild, resumable keyset backfill,
reconcile framework, freshness, purge/anonymize); this package owns ONLY the
sales semantics:
| This binding ships | Moved from be-prod |
|---|---|
| The salesFact/salesDaily projection spec — dimensions, the recognized-revenue measure model (qty + basis-explicit booked/fulfilled/returned/cancelled money), quality markers, 7-field rollup grain, live-collection index parity | sales-fact.model.ts, sales-daily.model.ts |
| Event mappers — order:created / order:fulfillment.transition+.completed (coverage-commit only) / order:change.confirmed / order:canceled → fact rows with business dedupe keys, authoritative payload occurredAt, aggregated same-line returns | sales-fact.projector.ts (pure half) |
| registerSalesProjector — relay-facing subscription wiring; THROWS on failure (ack contract); generation dual-write; partial-failure subscription rollback | sales-fact.projector.ts (IO half) |
| Document backfill mappers + DocumentSource bindings — placed/fulfilled/returned/cancelled, row-equivalent to live (money + skuRef + orderId) | sales-fact-backfill.ts |
| Reconcile source aggregations — every measure recomputed source-side; cell = civilDate × organizationId | sales-fact-reconcile.ts |
| Lens catalog (./lens subpath) — salesDaily/salesFact semantic models + curated branch/HQ named queries | analytics/semantic-models.ts, named-queries.ts |
The recognized-revenue measure model (v1 contract)
Money is basis-explicit — every money measure names WHEN it is recognized, so a reader never mistakes booked-at-placement demand for recognized-at-ship revenue. The LEDGER remains the authoritative money system of record; these projections are an analytical view (proportional, integer-minor, per-unit × qty), never the books.
Raw additive measures (on the fact row and summed into the daily rollup):
| Measure | Fact kind | Meaning |
|---|---|---|
| orderedQty / fulfilledQty / returnedQty / cancelledQty | each | whole units placed / shipped / returned / cancelled-remainder |
| bookedGrossMinor bookedDiscountMinor bookedTaxMinor bookedNetMinor bookedCogsMinor | ordered | booked at placement — order:created.lines[] money (no read). bookedNet = lineTotal = (unitPrice − unitDiscount + unitTax) × qty |
| fulfilledNetMinor fulfilledCogsMinor | fulfilled | recognized at ship — order-line unit net/cogs × the shipped delta qty |
| returnedNetMinor returnedCogsMinor | returned | reversal — unit net/cogs × returned qty |
| cancelledNetMinor cancelledCogsMinor | cancelled | reversal — unit net/cogs × cancelled remainder qty |
unitNet = unitPrice − unitDiscount + unitTax, unitCogs = snapshot.costPrice
— the single recognition basis (money.ts) shared by the live projector, the
backfill, and the reconcile so the three can never drift. Reversal/recognized
money is computed from the immutable order-line snapshot × qty via the
kernel's overflow-checked safeMulMinor.
Derived lens measures (never stored; ratio-of-sums — computed from each group's summed operands, NEVER a per-row average of ratios):
| Measure | Definition | Meaning |
|---|---|---|
| netAcceptedQty | orderedQty − cancelledQty | net accepted demand |
| bookedRevenue | bookedNet | booked at placement |
| netRevenue | bookedNet − cancelledNet − returnedNet | booked, net of reversals |
| recognizedRevenue | fulfilledNet − returnedNet | recognized at ship, net of returns |
| bookedMargin | bookedNet − bookedCogs | |
| netMargin | netRevenue − (bookedCogs − cancelledCogs − returnedCogs) | |
| recognizedMargin | recognizedRevenue − (fulfilledCogs − returnedCogs) | |
Host ports
| Port | Where from | Role |
|---|---|---|
| CivilCalendarPort | @classytic/facts (host binds e.g. Asia/Dhaka) | instant → civil date/week/month labels |
| OrderSourcePort | this package (ports.ts) | loadByNumber (skuRef + recognition money + org fallback for fulfilled/returned/cancelled) + optional loadChangeOrderRef (change → orderNumber) |
| EventTransport (subscribe) | @classytic/primitives | the dedicated, strict sales-projection lane (see below) |
| OutboxStore (FreshnessSource) | @classytic/primitives | getFreshness diagnostics over the projection's own outbox |
Boot
import { createSalesAnalytics, registerSalesProjector } from '@classytic/sales-analytics';
const sales = createSalesAnalytics({
connection: mongoose.connection,
civil: { dateOf, weekOf, monthOf }, // business zone (e.g. Asia/Dhaka)
orderSource: {
// ONE lean query — supplies skuRef, per-unit money (unitPrice/unitDiscount/
// unitTax + snapshot.costPrice) AND the organizationId fallback.
loadByNumber: (n) => order.models.Order.findOne({ orderNumber: n }).lean(),
loadChangeOrderRef: async (c) => { /* OrderChange → {orderId, orderNumber} */ },
},
autoIndex: false, // production: syncIndexes() at deploy
logger,
});
await sales.ensureReady({ skipIndexes: true }); // capability gate + collections
// Live projection — subscribe on the DEDICATED outbox-relay transport lane.
const registration = await registerSalesProjector({
engine: sales,
transport: salesProjectionTransport,
logger,
});
// on shutdown: await registration.dispose();The dedicated strict-transport requirement
transport MUST be the projection's OWN single-consumer lane, fed by the
outbox relay, whose publish propagates handler errors (primitives
InProcessEventBus propagateHandlerErrors: true, or equivalent). Handlers
THROW on any failure so the relay retries/dead-letters — a swallowed error
would ack an unprojected row (forbidden). On a partial subscribe failure the
registration unsubscribes everything already subscribed before rethrowing, so a
rejected subscribe never leaks the ones that succeeded.
sales is the kernel FactProjection — sales.manifest.*,
sales.projectFacts, sales.rebuildRollup, sales.runBackfill,
sales.reconcile, sales.getFreshness, sales.purgeGeneration,
sales.purgeOccurrence, sales.anonymizeFactWindow, sales.syncIndexes all
come straight from @classytic/facts. Import kernel types/helpers from
@classytic/facts directly — this package never re-exports them (P4).
Sequence: index → backfill → reconcile → cutover
ensureReady({ skipIndexes: true })thensyncIndexes()at deploy (a no-op against the live be-prod collections by the index-parity contract).manifest.beginRebuild()→ a shadow generation.runBackfill({ sources, generation, checkpointId })(below).reconcile(...)— ops refuse cutover unlessreport.summary.clean.manifest.markBackfillComplete→markVerified→cutover.purgeGeneration(retired)to reclaim the old generation.
Live-collection parity (the swap contract)
Collections sales_fact / sales_daily / sales_projection_manifest /
sales_projection_checkpoints; unique indexes gen_dedupeKey_unique and the
7-field daily_grain_unique (exact key order); the six unnamed secondaries in
be-prod's exact field orders. syncIndexes() is a no-op — pinned by the
spec-index test. The recognized-revenue measures are additive columns on
the same grain; the GRAIN and its unique index are unchanged.
Dedupe keys (business occurrences, never eventId):
placed:{orderNumber}:{lineId} · fulfilled:{fulfillmentNumber}:{orderLineId}
· returned:{changeNumber}:{orderLineId} · cancelled:{orderNumber}:{lineId}.
A change carrying two return_item actions for one line aggregates to ONE
returned fact (summed qty + money) — never a colliding second row.
Backfill
import { buildSalesBackfillSources } from '@classytic/sales-analytics';
const sources = buildSalesBackfillSources({
civil,
orders: { read: (q, n) => db.collection('orders').find(q).sort({ _id: 1 }).limit(n).toArray(),
estimateTotal: (f) => db.collection('orders').countDocuments(f) },
fulfillments: { read: (q, n) => db.collection('order_fulfillments').find(q).sort({ _id: 1 }).limit(n).toArray() },
changes: { read: (q, n) => db.collection('order_changes').find(q).sort({ _id: 1 }).limit(n).toArray() },
});
await sales.runBackfill({ sources, generation, checkpointId: 'rebuild-gen2' });Source names (placed/fulfilled/returned/cancelled) match be-prod's
historical checkpoints. The fulfilled/returned sources batch-resolve
parent-order line info (skuRef + unit net/cogs — the recognition basis) through
the same orders reader inside mapBatch, so a backfilled generation is
row-equivalent to live projection on orderId, skuRef AND money.
Reconcile (cutover gate)
import { reconcileSales } from '@classytic/sales-analytics';
const report = await reconcileSales(sales, {
generation, from: '2026-07-01', to: '2026-07-21',
reads: {
civil,
dayStartUtc: businessDayStartUtc,
dayEndUtc: businessDayEndUtc,
scanOrders: (q, p) => db.collection('orders').find(q, { projection: p }),
scanFulfillments: (q, p) => db.collection('order_fulfillments').find(q, { projection: p }),
scanChanges: (q, p) => db.collection('order_changes').find(q, { projection: p }),
},
});
// ops refuse manifest cutover unless report.summary.cleanCell = civilDate × organizationId. EVERY measure is recomputed independently
from the source docs (fulfilled/returned join the parent order via scanOrders
for the unit net/cogs) — so markVerified() can't pass with wrong money, only
wrong qty. Source timestamps convert to civil days through the SAME injected
civil port as the projector/backfill — one timezone implementation.
Lens catalog — @classytic/sales-analytics/lens
import {
buildSalesAnalyticsQueries,
salesAnalyticsSemanticModel,
} from '@classytic/sales-analytics/lens';
const queries = buildSalesAnalyticsQueries({
getGeneration: () => sales.manifest.getActiveGeneration(), // never client-supplied
});
// mount with @classytic/arc-lens: BRANCH surface ← queries.branch (organizationId
// force-injected; branch grouping rejected), HQ surface ← queries.hq (adds the
// sales-by-branch rollup grouped by organizationId).Own subpath on purpose: only this entry peers on @classytic/lens +
@classytic/arc-lens (optional peers) — the core binding never drags them in.
Purge / anonymize (retention & erasure)
The kernel surface is exposed verbatim on the engine:
sales.purgeGeneration(gen)— hard-purge a RETIRED generation (cutover cleanup).sales.purgeOccurrence(dedupeKey, { rebuild: true })— erase one business occurrence's fact rows and re-derive the affected rollup window.sales.anonymizeFactWindow({ generation, from, to, fields })— redact PII dimensions across a civilDate range while RETAINING measures.
The current sales fact carries no PII dimension (no customerId — it was
never captured), so anonymizeFactWindow is available for a FUTURE PII
dimension; nothing needs redacting today.
Order payload contract
@classytic/order is a type-only devDependency (floor >=0.12.0, erased at
build — zero runtime coupling). The consumed payload slices — lines[] /
shippedLines[] / returnedLines[] plus the now-consumed orderId and
occurredAt (the authoritative business instant: placedAt / coverageCommittedAt
/ confirmedAt / canceledAt) — are pinned against OrderEventPayloads by a
compile-time conformance test. Upstream payload drift fails this package's
typecheck, not production.
Peers
@classytic/facts (the kernel, >=0.2.0 — the purge/anonymize surface),
mongokit / primitives / repo-core / mongoose / zod floors per PACKAGE_RULES;
@classytic/lens + @classytic/arc-lens as OPTIONAL peers (only the ./lens
subpath needs them).
