@open-mercato/core 0.10.1-develop.7774.1.9a13ef7580 → 0.10.1-develop.7775.1.2a1f553826

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { TransactionContext } from "@mikro-orm/core";
1
2
  import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
2
3
  import { CatalogProduct, CatalogProductVariant } from "@open-mercato/core/modules/catalog/data/entities";
3
4
  import { CodeReservation } from "@open-mercato/core/modules/promotions/data/entities";
@@ -27,7 +28,7 @@ function giftLineKey(promotionId, productId, variantId) {
27
28
  const NO_CODE_LINKAGE = { code: null, codeId: null, codeType: null, codeReservationId: null };
28
29
  function buildPromotionsEffectsPhase(params) {
29
30
  const { em, container, cart, lines, assortmentScope, tenantScope, now, adjustments, warnings, createdGiftLines, removedGiftLines } = params;
30
- return async () => {
31
+ return () => TransactionContext.create(em, async () => {
31
32
  const promotionsService = container.resolve("promotionsService");
32
33
  const salesCalculationService = container.resolve("salesCalculationService");
33
34
  const existingApplications = await findWithDecryption(
@@ -311,7 +312,7 @@ function buildPromotionsEffectsPhase(params) {
311
312
  }
312
313
  }
313
314
  cart.promotionEffects = result.appliedPromotions;
314
- };
315
+ });
315
316
  }
316
317
  export {
317
318
  buildPromotionsEffectsPhase,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/cart/lib/promotionContext.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CatalogProduct, CatalogProductVariant } from '@open-mercato/core/modules/catalog/data/entities'\nimport type { ScopedProductScope } from '@open-mercato/core/modules/catalog/services/catalogScopedProductService'\nimport type { SalesCalculationService } from '@open-mercato/core/modules/sales/services/salesCalculationService'\nimport type { SalesAdjustmentDraft } from '@open-mercato/core/modules/sales/lib/types'\nimport { CodeReservation } from '@open-mercato/core/modules/promotions/data/entities'\nimport type {\n AddFreeItemEffect,\n BuyerContext,\n CartDiscountEffect,\n CodeRef,\n EvaluateItem,\n LineDiscountEffect,\n PromotionsService,\n ResolvedEffect,\n} from '@open-mercato/core/modules/promotions/lib/types'\nimport { Cart, CartLine, CartPromotionApplication } from '../data/entities'\nimport { cartVisibilityKey, evaluateCartVisibility, isVisible } from './visibility'\nimport type { CartVisibilityResult, ResolvedAssortmentScope } from './visibility'\n\n// Step 7.1 \u2014 the promotions integration phase behind `cart.reprice` (\u00A76/\u00A76a of\n// `.ai/specs/2026-08-14-cart-module.md`). DELIVERY_DISCOUNT effects are silently\n// dropped (D-R4-14/\u00A76: the cart never sends delivery context, so the engine should\n// never return one; a defensive filter rather than an assertion that throws if it\n// ever does).\n//\n// Step 7.2 \u2014 `codes` is no longer hardcoded `[]`. It is derived from this cart's own\n// `CartPromotionApplication` rows that carry a `codeId` (an ENTERED code \u2014 see the long\n// comment on the sync loop below for why that distinction matters), and every such row\n// also gets its reservation extended (\"cart activity\", D-R4-8) before `evaluate()` runs,\n// with a lapsed one dropped from `codes` and the row removed with a\n// `promotion_no_longer_valid` warning. `cart.promotions.apply` is the one place a NEW\n// code-bearing row is created (its own placeholder, persisted in its own transaction\n// before this phase's caller \u2014 `cart.reprice` \u2014 ever runs), so by the time this phase\n// reads `CartPromotionApplication` a just-applied code is already there.\n//\n// SPEC-018 discipline (`packages/core/AGENTS.md` \u2014 \"Entity Update Safety\"): this phase's\n// body is split into a READ section (the code housekeeping below, `promotionsService.evaluate()`,\n// the gift-line visibility batch check, the product/variant/line-numbering fetches \u2014 every\n// one of them a query against the SAME forked `em` the caller's earlier phases already\n// mutated) followed by a MUTATE section (line discount amounts, gift-line create/remove,\n// the CartPromotionApplication sync). No query runs after any mutation inside this phase \u2014\n// an interleaved read would silently drop the earlier scalar changes under MikroORM v7.\n// `promotionsService.extendReservation()`/the `CodeReservation` existence check below are\n// the one exception worth calling out explicitly: they run against promotions' OWN\n// `EntityManager` fork (`extendReservation`'s own `resolvePromotionsEm`) or a read-only\n// query on THIS `em` before any mutation \u2014 neither one is a read-after-mutate hazard on\n// this phase's own `em`.\n\n/**\n * Maps a `CartLine` onto `promotions`' `EvaluateItem` contract (\u00A76: \"sends only what the\n * cart knows: ids, prices and quantities\"). `priceIsPromotional` is hardcoded `false` \u2014\n * D-R4-16 names this per-item signal, but nothing upstream of `cart` (catalog pricing,\n * `ResolvedLinePrice`) currently populates \"this unit price came from a promotional price\n * kind\" anywhere in this worktree (checked). That is a gap for whoever wires catalog\n * promotional pricing into this field later, not this step's job to invent.\n */\nexport function toPromotionCartLine(line: CartLine): EvaluateItem {\n return {\n lineId: line.id,\n productId: line.productId,\n variantId: line.variantId ?? null,\n quantity: line.quantity,\n unitPriceNet: line.unitPriceNet ?? '0',\n unitPriceGross: line.unitPriceGross ?? '0',\n priceIsPromotional: false,\n }\n}\n\nexport type CartPromotionWarning =\n | { code: 'product_unavailable'; productId: string; variantId: string | null }\n // Step 7.2 \u2014 D-R4-8: \"the cart extends on activity and emits `cart.promotion.no_longer_valid`\n // plus a warning when a reservation lapses.\" Broadened the union rather than adding a\n // sibling type (judgment call) \u2014 every existing consumer of `CartPromotionWarning`\n // (`CartRepriceResult.warnings`, `lib/cartView.ts`'s eventual warnings merge) already\n // treats it as one flat array, and a `code`-discriminated union reads naturally there.\n | { code: 'promotion_no_longer_valid'; promotionId: string }\n\ntype CartPromotionsContainer = {\n resolve: <T = unknown>(name: string) => T\n}\n\n/**\n * `effect.label` is a `Record<string, string>` i18n map (SPEC-055 \u00A7Public TypeScript\n * Contract) with no contract-mandated key to prefer. Judgment call: try the cart's own\n * locale verbatim, then its language subtag (`en-US` -> `en`), then fall back to whatever\n * key happens to be first \u2014 never throw for a promotion authored without the buyer's\n * locale. `null` only when the map itself is empty.\n */\nfunction pickEffectLabel(labels: Record<string, string>, locale: string): string | null {\n if (labels[locale]) return labels[locale]\n const [language] = locale.split('-')\n if (language && labels[language]) return labels[language]\n const values = Object.values(labels)\n return values.length > 0 ? values[0] : null\n}\n\nfunction giftLineKey(promotionId: string, productId: string, variantId: string | null): string {\n return `${promotionId}:${productId}:${variantId ?? ''}`\n}\n\ntype ApplicationEntry = {\n cartLineId: string | null\n effect: ResolvedEffect\n amountNet: string | null\n amountGross: string | null\n}\n\n/** Step 7.2 \u2014 carried from an existing code-bearing row onto whatever row(s) the current\n * evaluation re-derives for the SAME promotion, so a promotion's code linkage survives its\n * effect moving to a different line (or appearing for the first time this cycle) instead\n * of being silently nulled out by a fresh `em.create()`. */\ntype CodeLinkage = {\n code: string | null\n codeId: string | null\n codeType: string | null\n codeReservationId: string | null\n}\n\nconst NO_CODE_LINKAGE: CodeLinkage = { code: null, codeId: null, codeType: null, codeReservationId: null }\n\nexport type BuildPromotionsEffectsPhaseParams = {\n em: EntityManager\n container: CartPromotionsContainer\n cart: Cart\n /**\n * The cart's full non-deleted `CartLine` set. Mutated in place: a newly created gift line\n * is pushed on; a gift line no longer present in this evaluation is spliced out (after its\n * own `deletedAt` is set) \u2014 so a later phase in the SAME `withAtomicFlush` sequence that\n * closes over this same array (the totals recompute) sees the current, post-promotion set\n * without a second DB round trip.\n */\n lines: CartLine[]\n assortmentScope: ResolvedAssortmentScope | undefined\n tenantScope: ScopedProductScope\n now: Date\n /** `CART_DISCOUNT` effects are pushed here as order-scope `SalesAdjustmentDraft`s. */\n adjustments: SalesAdjustmentDraft[]\n /** A rejected `ADD_FREE_ITEM` (assortment-unavailable gift) or a lapsed code reservation\n * is pushed here (\u00A76a.3 / D-R4-8). */\n warnings: CartPromotionWarning[]\n /** Newly created gift `CartLine`s, for the caller's post-commit side effects. */\n createdGiftLines: CartLine[]\n /** Gift `CartLine`s removed because their promotion no longer applies, same reason. */\n removedGiftLines: CartLine[]\n}\n\n/**\n * Builds the ONE `withAtomicFlush` phase that: calls `promotionsService.evaluate()` against\n * the cart's non-flagged, non-gift lines (and its own currently-held, still-reserved codes \u2014\n * Step 7.2); maps `LINE_DISCOUNT` onto `CartLine.discountTotalNet` and `CART_DISCOUNT` onto\n * `adjustments`; creates/removes `ADD_FREE_ITEM` gift lines (assortment-checked, buyer-immutable\n * per `commands/lines.ts`'s existing `promotion_line_immutable` guard); drops `DELIVERY_DISCOUNT`;\n * extends every held code's reservation (\"cart activity\", D-R4-8); and syncs\n * `CartPromotionApplication` rows to the current evaluation.\n *\n * Caller contract (`commands/cart.ts`'s `cart.reprice`): `container` must already have `auth`\n * registered (`{ tenantId, orgId }`) before this phase runs, or `promotionsService.evaluate()`/\n * `.extendReservation()` fail closed with a 400 (`lib/promotions-service.ts`'s\n * `resolvePromotionsServiceScope`/`resolvePromotionsServiceAuthAndScope`).\n */\nexport function buildPromotionsEffectsPhase(params: BuildPromotionsEffectsPhaseParams): () => Promise<void> {\n const { em, container, cart, lines, assortmentScope, tenantScope, now, adjustments, warnings, createdGiftLines, removedGiftLines } = params\n\n return async () => {\n const promotionsService = container.resolve<PromotionsService>('promotionsService')\n const salesCalculationService = container.resolve<SalesCalculationService>('salesCalculationService')\n\n // ---- READ section part 1: this cart's currently-held codes, BEFORE evaluate() (its\n // `codes` input is derived from this), extended + lapse-checked (\"cart activity extends\n // a reservation\", D-R4-8) ----\n const existingApplications = (await findWithDecryption(\n em,\n CartPromotionApplication,\n { cartId: cart.id, tenantId: cart.tenantId, organizationId: cart.organizationId },\n {},\n { tenantId: cart.tenantId, organizationId: cart.organizationId },\n )) as CartPromotionApplication[]\n\n const codeIdsInUse = Array.from(\n new Set(existingApplications.map((application) => application.codeId).filter((id): id is string => !!id)),\n )\n // A code-id's reservation lives on `promotions`' own tables, not this cart's `em` \u2014 so\n // extending it and then re-checking it still exists is two round trips against a\n // DIFFERENT EntityManager (`extendReservation`'s own `resolvePromotionsEm` fork) plus a\n // plain cross-module read on THIS `em`, never a read-after-mutate hazard on this phase's\n // own pending changes (there are none yet at this point in the phase).\n const lapsedCodeIds = new Set<string>()\n for (const codeId of codeIdsInUse) {\n await promotionsService.extendReservation(codeId, cart.id)\n const stillReserved = await em.findOne(CodeReservation, {\n codeId,\n cartId: cart.id,\n tenantId: tenantScope.tenantId,\n organizationId: tenantScope.organizationId,\n })\n if (!stillReserved) lapsedCodeIds.add(codeId)\n }\n\n // One `CodeRef` per distinct, still-reserved codeId \u2014 a promotion with a per-line\n // benefit can have several `CartPromotionApplication` rows sharing the same `codeId`\n // (one per target line), so this dedupes rather than sending duplicates to `evaluate()`.\n const codesByCodeId = new Map<string, CodeRef>()\n const codeLinkageByPromotion = new Map<string, CodeLinkage>()\n for (const application of existingApplications) {\n if (!application.codeId) continue\n if (!lapsedCodeIds.has(application.codeId)) {\n codesByCodeId.set(application.codeId, {\n id: application.codeId,\n type: (application.codeType ?? 'static') as 'static' | 'dynamic',\n })\n }\n // Step 7.2 \u2014 captured regardless of lapsed status: a lapsed code's OWN rows are\n // removed below (never re-created), so there is nothing to carry linkage onto for it;\n // harmless to still index it here since `appliedPromotionIds` can never include a\n // promotion whose only code just lapsed and was excluded from `codes` above.\n if (!codeLinkageByPromotion.has(application.promotionId)) {\n codeLinkageByPromotion.set(application.promotionId, {\n code: application.code ?? null,\n codeId: application.codeId,\n codeType: application.codeType ?? null,\n codeReservationId: application.codeReservationId ?? null,\n })\n }\n }\n const codes = Array.from(codesByCodeId.values())\n\n // \u00A74.2/\u00A76a.2: a flagged line is excluded from evaluation. Gift lines are evaluator\n // OUTPUT, not input \u2014 re-evaluating them as purchasable items would double-count.\n const evaluableLines = lines.filter((line) => line.flagState == null && line.origin !== 'promotion')\n const buyer: BuyerContext = {\n customerId: cart.customerId ?? null,\n customerIds: cart.customerIds ?? [],\n customerGroupIds: cart.customerGroupIds,\n }\n\n const result = await promotionsService.evaluate({\n items: evaluableLines.map(toPromotionCartLine),\n currency: cart.currencyCode,\n evaluatedAt: now.toISOString(),\n buyer,\n // Neither field has a resolvable source in `cart` yet (no order-history lookup, no\n // weight aggregation) \u2014 `null` is correct per the type, not a shortcut. Revisit if a\n // rule a test fixture exercises turns out to gate on either field.\n customerOrderCount: null,\n cartWeight: null,\n codes,\n })\n\n // ---- Pure in-memory classification of the result (no `em` access yet) ----\n const lineById = new Map(lines.map((line) => [line.id, line]))\n const lineDiscountTargets: Array<{ promotionId: string; line: CartLine; effect: LineDiscountEffect }> = []\n const cartDiscountEntries: Array<{ promotionId: string; effect: CartDiscountEffect }> = []\n const freeItemEffects: Array<{ promotionId: string; effect: AddFreeItemEffect }> = []\n\n for (const applied of result.appliedPromotions) {\n for (const effect of applied.effects) {\n if (effect.type === 'LINE_DISCOUNT') {\n const target = lineById.get(effect.targetLineId)\n if (target) lineDiscountTargets.push({ promotionId: applied.promotionId, line: target, effect })\n } else if (effect.type === 'CART_DISCOUNT') {\n cartDiscountEntries.push({ promotionId: applied.promotionId, effect })\n } else if (effect.type === 'ADD_FREE_ITEM') {\n freeItemEffects.push({ promotionId: applied.promotionId, effect })\n }\n // `DELIVERY_DISCOUNT` \u2014 dropped (D-R4-14/\u00A76): never mapped to anything.\n }\n }\n\n const existingGiftLines = lines.filter((line) => line.origin === 'promotion')\n const existingGiftByKey = new Map(\n existingGiftLines.map((line) => [giftLineKey(line.promotionId as string, line.productId, line.variantId ?? null), line]),\n )\n const keepKeys = new Set(\n freeItemEffects.map(({ promotionId, effect }) => giftLineKey(promotionId, effect.productId, effect.variantId ?? null)),\n )\n const giftLinesToRemove = Array.from(existingGiftByKey.entries())\n .filter(([key]) => !keepKeys.has(key))\n .map(([, line]) => line)\n const newFreeItemEffects = freeItemEffects.filter(\n ({ promotionId, effect }) => !existingGiftByKey.has(giftLineKey(promotionId, effect.productId, effect.variantId ?? null)),\n )\n\n // ---- READ section part 2: every remaining `em` query this phase needs, before any\n // mutation below ----\n let visibility: CartVisibilityResult = new Map()\n let productsById = new Map<string, CatalogProduct>()\n let variantsById = new Map<string, CatalogProductVariant>()\n let nextLineNumber = 0\n if (newFreeItemEffects.length > 0) {\n const visibilityRefs = newFreeItemEffects.map(({ effect }) => ({ productId: effect.productId, variantId: effect.variantId ?? null }))\n visibility = await evaluateCartVisibility({ lines: visibilityRefs, scope: assortmentScope, tenantScope, em, container })\n\n const productIds = Array.from(new Set(newFreeItemEffects.map(({ effect }) => effect.productId)))\n const products = (await findWithDecryption(\n em,\n CatalogProduct,\n { id: { $in: productIds }, tenantId: tenantScope.tenantId, organizationId: tenantScope.organizationId, deletedAt: null },\n {},\n tenantScope,\n )) as CatalogProduct[]\n productsById = new Map(products.map((product) => [product.id, product]))\n\n const variantIds = Array.from(\n new Set(newFreeItemEffects.map(({ effect }) => effect.variantId).filter((id): id is string => !!id)),\n )\n if (variantIds.length > 0) {\n const variants = (await findWithDecryption(\n em,\n CatalogProductVariant,\n { id: { $in: variantIds }, tenantId: tenantScope.tenantId, organizationId: tenantScope.organizationId, deletedAt: null },\n {},\n tenantScope,\n )) as CatalogProductVariant[]\n variantsById = new Map(variants.map((variant) => [variant.id, variant]))\n }\n\n // Mirrors `commands/lines.ts`'s `computeStartingLineNumber` exactly (plain `em.find`,\n // no decryption wrapper \u2014 `CartLine` carries no encrypted columns): includes\n // soft-deleted lines too, so a freshly minted gift line never reuses a retired number.\n const allLinesForNumbering = await em.find(CartLine, {\n cartId: cart.id,\n tenantId: tenantScope.tenantId,\n organizationId: tenantScope.organizationId,\n })\n nextLineNumber = allLinesForNumbering.reduce((max, line) => Math.max(max, line.lineNumber), 0) + 1\n }\n\n // ---- MUTATE section: no more `em` reads past this point in this phase ----\n // Step 8.2 (QA F12) \u2014 every evaluated line's discount is re-derived from THIS\n // evaluation, not merged with its previous one: a line that dropped out of\n // `lineDiscountTargets` since the last evaluation (e.g. its quantity just fell below the\n // promotion's own \"2+ items\" threshold) must lose its stale `discountTotalNet`, not keep\n // whatever a PRIOR evaluation last wrote. Clearing every `evaluableLine` first, then\n // reapplying below for whatever is still targeted, makes that hold unconditionally\n // without a second lookup of which lines changed.\n for (const line of evaluableLines) {\n if (line.discountTotalNet !== null) line.discountTotalNet = null\n }\n for (const { line, effect } of lineDiscountTargets) {\n line.discountTotalNet = effect.amountNet\n }\n\n for (const giftLine of giftLinesToRemove) {\n giftLine.deletedAt = now\n const index = lines.indexOf(giftLine)\n if (index >= 0) lines.splice(index, 1)\n removedGiftLines.push(giftLine)\n }\n\n const createdGiftLineByKey = new Map<string, CartLine>()\n for (const { promotionId, effect } of newFreeItemEffects) {\n const ref = { productId: effect.productId, variantId: effect.variantId ?? null }\n const visible = isVisible(visibility.get(cartVisibilityKey(ref)) ?? 'unavailable')\n if (!visible) {\n warnings.push({ code: 'product_unavailable', productId: effect.productId, variantId: effect.variantId ?? null })\n continue\n }\n const product = productsById.get(effect.productId) ?? null\n if (!product) {\n // \u00A76a.3: a restricted product and a missing one collapse to the same outcome.\n warnings.push({ code: 'product_unavailable', productId: effect.productId, variantId: effect.variantId ?? null })\n continue\n }\n const variant = effect.variantId ? variantsById.get(effect.variantId) ?? null : null\n\n // \u00A74.2: `lineTotals` is \"verbatim SalesLineCalculationResult\" for every line, free\n // gift included \u2014 never a placeholder `{}` (that would ship a line whose own total\n // snapshot lies about having one). All-zero amounts in, all-zero amounts out.\n const giftLineTotals = await salesCalculationService.calculateLine({\n documentKind: 'quote',\n line: {\n kind: 'product',\n productId: product.id,\n productVariantId: variant?.id ?? null,\n name: product.title,\n quantity: effect.quantity,\n currencyCode: cart.currencyCode,\n unitPriceNet: 0,\n unitPriceGross: 0,\n taxRate: null,\n configuration: null,\n },\n context: { tenantId: cart.tenantId, organizationId: cart.organizationId, currencyCode: cart.currencyCode },\n })\n\n const giftLine = em.create(CartLine, {\n organizationId: cart.organizationId,\n tenantId: cart.tenantId,\n cartId: cart.id,\n lineNumber: nextLineNumber,\n productId: product.id,\n variantId: variant?.id ?? null,\n sku: variant?.sku ?? product.sku ?? null,\n name: product.title,\n optionValues: variant?.optionValues ?? null,\n quantity: String(effect.quantity),\n unitPriceNet: '0',\n unitPriceGross: '0',\n taxRate: null,\n origin: 'promotion' as const,\n promotionId,\n discountTotalNet: null,\n flagState: null,\n configuration: null,\n addedByActorType: 'system' as const,\n addedByActorId: null,\n metadata: null,\n pricedAt: now,\n lineTotals: giftLineTotals as unknown as Record<string, unknown>,\n })\n nextLineNumber += 1\n em.persist(giftLine)\n lines.push(giftLine)\n createdGiftLines.push(giftLine)\n createdGiftLineByKey.set(giftLineKey(promotionId, effect.productId, effect.variantId ?? null), giftLine)\n }\n\n // ---- Build the (promotionId -> applications) map now that every line mutation above\n // has resolved (gift lines created, discounts applied) \u2014 pure in-memory, no `em` access.\n const applicationsByPromotion = new Map<string, ApplicationEntry[]>()\n const pushApplication = (promotionId: string, entry: ApplicationEntry): void => {\n const entries = applicationsByPromotion.get(promotionId) ?? []\n entries.push(entry)\n applicationsByPromotion.set(promotionId, entries)\n }\n\n for (const { promotionId, line, effect } of lineDiscountTargets) {\n pushApplication(promotionId, { cartLineId: line.id, effect, amountNet: effect.amountNet, amountGross: effect.amountGross })\n }\n for (const { promotionId, effect } of cartDiscountEntries) {\n adjustments.push({\n scope: 'order',\n kind: 'discount',\n promotionId,\n amountNet: Number(effect.amountNet),\n amountGross: Number(effect.amountGross),\n currencyCode: effect.currency,\n label: pickEffectLabel(effect.label, cart.locale),\n })\n pushApplication(promotionId, { cartLineId: null, effect, amountNet: effect.amountNet, amountGross: effect.amountGross })\n }\n for (const { promotionId, effect } of newFreeItemEffects) {\n const createdLine = createdGiftLineByKey.get(giftLineKey(promotionId, effect.productId, effect.variantId ?? null))\n // A rejected gift (visibility/missing product) never got a line \u2014 no application row.\n if (!createdLine) continue\n pushApplication(promotionId, { cartLineId: createdLine.id, effect, amountNet: null, amountGross: null })\n }\n\n // ---- Sync `CartPromotionApplication` rows to the current evaluation (mutations only:\n // `existingApplications` was already fetched in the READ section above) ----\n //\n // Step 7.2 \u2014 THE KEY DESIGN DECISION (documented here, not just in the run's own\n // notes, since this is the single most load-bearing piece of reasoning in this phase):\n // `CartPromotionApplication.promotionId` is NOT NULL, and Step 7.1's original sync\n // logic removed ANY row whose `promotionId` was absent from this evaluation's\n // `appliedPromotions` \u2014 correct for an AUTOMATIC (codeless) promotion, where \"absent\n // from the result\" really does mean \"no longer applies, forget it.\" It is WRONG for a\n // CODE-bearing row: a code that reserves successfully but whose promotion's rule tree\n // does not currently match the cart's composition (e.g. a minimum-cart-value rule) also\n // comes back absent from `appliedPromotions` \u2014 not because the code was removed, but\n // because the rule simply does not match RIGHT NOW. Deleting that row on the very first\n // re-price after applying the code would erase the only record the code was ever\n // entered; the next re-price would feed `evaluate()` a `codes` list with nothing left to\n // rebuild it from, and the code is lost even though the buyer never removed it and the\n // reservation is still live.\n //\n // The fix: a row with a non-null `codeId` is \"an entered code,\" not merely \"a currently-\n // active discount.\" It is removed ONLY by (a) this phase's own lapsed-reservation check\n // above (the code itself is gone), or (b) an explicit `cart.promotions.remove`\n // (`commands/promotions.ts`) \u2014 never by this automatic sync just because its promotion\n // produced no effect THIS cycle. When that happens, the row survives with its effect\n // zeroed out (`effect: {}`, `amountNet`/`amountGross`: null, `cartLineId: null` \u2014 it no\n // longer points at a specific line because it is not discounting one) rather than being\n // deleted. A codeless (automatic) row keeps Step 7.1's exact original behavior: pruned\n // whenever its promotion is absent from the result.\n //\n // Removal is otherwise still keyed on (promotionId, cartLineId), not promotionId alone:\n // a promotion that stays applied but whose effect now targets a different line (or\n // switched which product it gifts) must drop its OLD row, not just leave it orphaned\n // pointing at a line that is no longer part of this evaluation \u2014 a FRESH row for the\n // same promotion is created in the loop below, with its code linkage (if any) carried\n // over via `codeLinkageByPromotion` so a promotion's code binding survives its effect\n // moving between lines.\n const appliedPromotionIds = new Set(result.appliedPromotions.map((applied) => applied.promotionId))\n const keepApplicationKeys = new Set<string>()\n for (const [promotionId, entries] of applicationsByPromotion) {\n for (const entry of entries) {\n keepApplicationKeys.add(`${promotionId}:${entry.cartLineId ?? ''}`)\n }\n }\n for (const existingApplication of existingApplications) {\n const codeId = existingApplication.codeId ?? null\n\n if (codeId != null && lapsedCodeIds.has(codeId)) {\n // The reservation backing this entered code is gone (expired, or released by a\n // promotions-side sweep) \u2014 D-R4-8's \"emits `cart.promotion.no_longer_valid` plus a\n // warning when a reservation lapses\". Removed unconditionally, even if this\n // promotion happens to still match THIS evaluation via some other, code-independent\n // rule \u2014 the ENTERED CODE itself is gone either way.\n em.remove(existingApplication)\n warnings.push({ code: 'promotion_no_longer_valid', promotionId: existingApplication.promotionId })\n continue\n }\n\n const key = `${existingApplication.promotionId}:${existingApplication.cartLineId ?? ''}`\n if (appliedPromotionIds.has(existingApplication.promotionId) && keepApplicationKeys.has(key)) {\n // Still current \u2014 the update loop below will refresh its amounts in place.\n continue\n }\n\n if (!appliedPromotionIds.has(existingApplication.promotionId) && codeId != null) {\n // THE KEY DESIGN DECISION above: keep the entered-code row, zero its effect.\n existingApplication.effect = {} as Record<string, unknown>\n existingApplication.amountNet = null\n existingApplication.amountGross = null\n existingApplication.cartLineId = null\n continue\n }\n\n em.remove(existingApplication)\n }\n\n for (const [promotionId, entries] of applicationsByPromotion) {\n for (const entry of entries) {\n const matched = existingApplications.find(\n (application) =>\n application.promotionId === promotionId && (application.cartLineId ?? null) === entry.cartLineId,\n )\n if (matched) {\n matched.amountNet = entry.amountNet\n matched.amountGross = entry.amountGross\n matched.effect = entry.effect as unknown as Record<string, unknown>\n matched.appliedAt = now\n continue\n }\n // Step 7.2 \u2014 a NEW row for a promotion that already had a code-bearing row under a\n // DIFFERENT key (e.g. its `LINE_DISCOUNT` target moved to another line) carries that\n // code's linkage forward; a promotion with no held code gets `NO_CODE_LINKAGE`,\n // exactly Step 7.1's original behavior.\n const linkage = codeLinkageByPromotion.get(promotionId) ?? NO_CODE_LINKAGE\n const application = em.create(CartPromotionApplication, {\n organizationId: cart.organizationId,\n tenantId: cart.tenantId,\n cartId: cart.id,\n promotionId,\n code: linkage.code,\n codeId: linkage.codeId,\n codeType: linkage.codeType,\n codeReservationId: linkage.codeReservationId,\n scope: entry.cartLineId ? ('line' as const) : ('order' as const),\n cartLineId: entry.cartLineId,\n effect: entry.effect as unknown as Record<string, unknown>,\n amountNet: entry.amountNet,\n amountGross: entry.amountGross,\n appliedAt: now,\n })\n em.persist(application)\n }\n }\n\n // \u00A74.1's own column exists but nothing reads it yet (checked `lib/cartView.ts` \u2014 no\n // `promotionEffects` consumer today beyond `commands/cart.ts`'s `toCartSummary` passing\n // it through verbatim). Stored raw rather than a trimmed projection so a future badge UI\n // has the full `appliedPromotions` shape to read from, same \"found and closed before\n // anyone needed it\" style this run's earlier steps use for a half-wired field.\n cart.promotionEffects = result.appliedPromotions\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { TransactionContext } from '@mikro-orm/core'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CatalogProduct, CatalogProductVariant } from '@open-mercato/core/modules/catalog/data/entities'\nimport type { ScopedProductScope } from '@open-mercato/core/modules/catalog/services/catalogScopedProductService'\nimport type { SalesCalculationService } from '@open-mercato/core/modules/sales/services/salesCalculationService'\nimport type { SalesAdjustmentDraft } from '@open-mercato/core/modules/sales/lib/types'\nimport { CodeReservation } from '@open-mercato/core/modules/promotions/data/entities'\nimport type {\n AddFreeItemEffect,\n BuyerContext,\n CartDiscountEffect,\n CodeRef,\n EvaluateItem,\n LineDiscountEffect,\n PromotionsService,\n ResolvedEffect,\n} from '@open-mercato/core/modules/promotions/lib/types'\nimport { Cart, CartLine, CartPromotionApplication } from '../data/entities'\nimport { cartVisibilityKey, evaluateCartVisibility, isVisible } from './visibility'\nimport type { CartVisibilityResult, ResolvedAssortmentScope } from './visibility'\n\n// Step 7.1 \u2014 the promotions integration phase behind `cart.reprice` (\u00A76/\u00A76a of\n// `.ai/specs/2026-08-14-cart-module.md`). DELIVERY_DISCOUNT effects are silently\n// dropped (D-R4-14/\u00A76: the cart never sends delivery context, so the engine should\n// never return one; a defensive filter rather than an assertion that throws if it\n// ever does).\n//\n// Step 7.2 \u2014 `codes` is no longer hardcoded `[]`. It is derived from this cart's own\n// `CartPromotionApplication` rows that carry a `codeId` (an ENTERED code \u2014 see the long\n// comment on the sync loop below for why that distinction matters), and every such row\n// also gets its reservation extended (\"cart activity\", D-R4-8) before `evaluate()` runs,\n// with a lapsed one dropped from `codes` and the row removed with a\n// `promotion_no_longer_valid` warning. `cart.promotions.apply` is the one place a NEW\n// code-bearing row is created (its own placeholder, persisted in its own transaction\n// before this phase's caller \u2014 `cart.reprice` \u2014 ever runs), so by the time this phase\n// reads `CartPromotionApplication` a just-applied code is already there.\n//\n// SPEC-018 discipline (`packages/core/AGENTS.md` \u2014 \"Entity Update Safety\"): this phase's\n// body is split into a READ section (the code housekeeping below, `promotionsService.evaluate()`,\n// the gift-line visibility batch check, the product/variant/line-numbering fetches \u2014 every\n// one of them a query against the SAME forked `em` the caller's earlier phases already\n// mutated) followed by a MUTATE section (line discount amounts, gift-line create/remove,\n// the CartPromotionApplication sync). No query runs after any mutation inside this phase \u2014\n// an interleaved read would silently drop the earlier scalar changes under MikroORM v7.\n// `promotionsService.extendReservation()`/the `CodeReservation` existence check below are\n// the one exception worth calling out explicitly: they run against promotions' OWN\n// `EntityManager` fork (`extendReservation`'s own `resolvePromotionsEm`) or a read-only\n// query on THIS `em` before any mutation \u2014 neither one is a read-after-mutate hazard on\n// this phase's own `em`.\n\n/**\n * Maps a `CartLine` onto `promotions`' `EvaluateItem` contract (\u00A76: \"sends only what the\n * cart knows: ids, prices and quantities\"). `priceIsPromotional` is hardcoded `false` \u2014\n * D-R4-16 names this per-item signal, but nothing upstream of `cart` (catalog pricing,\n * `ResolvedLinePrice`) currently populates \"this unit price came from a promotional price\n * kind\" anywhere in this worktree (checked). That is a gap for whoever wires catalog\n * promotional pricing into this field later, not this step's job to invent.\n */\nexport function toPromotionCartLine(line: CartLine): EvaluateItem {\n return {\n lineId: line.id,\n productId: line.productId,\n variantId: line.variantId ?? null,\n quantity: line.quantity,\n unitPriceNet: line.unitPriceNet ?? '0',\n unitPriceGross: line.unitPriceGross ?? '0',\n priceIsPromotional: false,\n }\n}\n\nexport type CartPromotionWarning =\n | { code: 'product_unavailable'; productId: string; variantId: string | null }\n // Step 7.2 \u2014 D-R4-8: \"the cart extends on activity and emits `cart.promotion.no_longer_valid`\n // plus a warning when a reservation lapses.\" Broadened the union rather than adding a\n // sibling type (judgment call) \u2014 every existing consumer of `CartPromotionWarning`\n // (`CartRepriceResult.warnings`, `lib/cartView.ts`'s eventual warnings merge) already\n // treats it as one flat array, and a `code`-discriminated union reads naturally there.\n | { code: 'promotion_no_longer_valid'; promotionId: string }\n\ntype CartPromotionsContainer = {\n resolve: <T = unknown>(name: string) => T\n}\n\n/**\n * `effect.label` is a `Record<string, string>` i18n map (SPEC-055 \u00A7Public TypeScript\n * Contract) with no contract-mandated key to prefer. Judgment call: try the cart's own\n * locale verbatim, then its language subtag (`en-US` -> `en`), then fall back to whatever\n * key happens to be first \u2014 never throw for a promotion authored without the buyer's\n * locale. `null` only when the map itself is empty.\n */\nfunction pickEffectLabel(labels: Record<string, string>, locale: string): string | null {\n if (labels[locale]) return labels[locale]\n const [language] = locale.split('-')\n if (language && labels[language]) return labels[language]\n const values = Object.values(labels)\n return values.length > 0 ? values[0] : null\n}\n\nfunction giftLineKey(promotionId: string, productId: string, variantId: string | null): string {\n return `${promotionId}:${productId}:${variantId ?? ''}`\n}\n\ntype ApplicationEntry = {\n cartLineId: string | null\n effect: ResolvedEffect\n amountNet: string | null\n amountGross: string | null\n}\n\n/** Step 7.2 \u2014 carried from an existing code-bearing row onto whatever row(s) the current\n * evaluation re-derives for the SAME promotion, so a promotion's code linkage survives its\n * effect moving to a different line (or appearing for the first time this cycle) instead\n * of being silently nulled out by a fresh `em.create()`. */\ntype CodeLinkage = {\n code: string | null\n codeId: string | null\n codeType: string | null\n codeReservationId: string | null\n}\n\nconst NO_CODE_LINKAGE: CodeLinkage = { code: null, codeId: null, codeType: null, codeReservationId: null }\n\nexport type BuildPromotionsEffectsPhaseParams = {\n em: EntityManager\n container: CartPromotionsContainer\n cart: Cart\n /**\n * The cart's full non-deleted `CartLine` set. Mutated in place: a newly created gift line\n * is pushed on; a gift line no longer present in this evaluation is spliced out (after its\n * own `deletedAt` is set) \u2014 so a later phase in the SAME `withAtomicFlush` sequence that\n * closes over this same array (the totals recompute) sees the current, post-promotion set\n * without a second DB round trip.\n */\n lines: CartLine[]\n assortmentScope: ResolvedAssortmentScope | undefined\n tenantScope: ScopedProductScope\n now: Date\n /** `CART_DISCOUNT` effects are pushed here as order-scope `SalesAdjustmentDraft`s. */\n adjustments: SalesAdjustmentDraft[]\n /** A rejected `ADD_FREE_ITEM` (assortment-unavailable gift) or a lapsed code reservation\n * is pushed here (\u00A76a.3 / D-R4-8). */\n warnings: CartPromotionWarning[]\n /** Newly created gift `CartLine`s, for the caller's post-commit side effects. */\n createdGiftLines: CartLine[]\n /** Gift `CartLine`s removed because their promotion no longer applies, same reason. */\n removedGiftLines: CartLine[]\n}\n\n/**\n * Builds the ONE `withAtomicFlush` phase that: calls `promotionsService.evaluate()` against\n * the cart's non-flagged, non-gift lines (and its own currently-held, still-reserved codes \u2014\n * Step 7.2); maps `LINE_DISCOUNT` onto `CartLine.discountTotalNet` and `CART_DISCOUNT` onto\n * `adjustments`; creates/removes `ADD_FREE_ITEM` gift lines (assortment-checked, buyer-immutable\n * per `commands/lines.ts`'s existing `promotion_line_immutable` guard); drops `DELIVERY_DISCOUNT`;\n * extends every held code's reservation (\"cart activity\", D-R4-8); and syncs\n * `CartPromotionApplication` rows to the current evaluation.\n *\n * Caller contract (`commands/cart.ts`'s `cart.reprice`): `container` must already have `auth`\n * registered (`{ tenantId, orgId }`) before this phase runs, or `promotionsService.evaluate()`/\n * `.extendReservation()` fail closed with a 400 (`lib/promotions-service.ts`'s\n * `resolvePromotionsServiceScope`/`resolvePromotionsServiceAuthAndScope`).\n */\nexport function buildPromotionsEffectsPhase(params: BuildPromotionsEffectsPhaseParams): () => Promise<void> {\n const { em, container, cart, lines, assortmentScope, tenantScope, now, adjustments, warnings, createdGiftLines, removedGiftLines } = params\n\n return () => TransactionContext.create(em, async () => {\n const promotionsService = container.resolve<PromotionsService>('promotionsService')\n const salesCalculationService = container.resolve<SalesCalculationService>('salesCalculationService')\n\n // ---- READ section part 1: this cart's currently-held codes, BEFORE evaluate() (its\n // `codes` input is derived from this), extended + lapse-checked (\"cart activity extends\n // a reservation\", D-R4-8) ----\n const existingApplications = (await findWithDecryption(\n em,\n CartPromotionApplication,\n { cartId: cart.id, tenantId: cart.tenantId, organizationId: cart.organizationId },\n {},\n { tenantId: cart.tenantId, organizationId: cart.organizationId },\n )) as CartPromotionApplication[]\n\n const codeIdsInUse = Array.from(\n new Set(existingApplications.map((application) => application.codeId).filter((id): id is string => !!id)),\n )\n const lapsedCodeIds = new Set<string>()\n for (const codeId of codeIdsInUse) {\n await promotionsService.extendReservation(codeId, cart.id)\n const stillReserved = await em.findOne(CodeReservation, {\n codeId,\n cartId: cart.id,\n tenantId: tenantScope.tenantId,\n organizationId: tenantScope.organizationId,\n })\n if (!stillReserved) lapsedCodeIds.add(codeId)\n }\n\n // One `CodeRef` per distinct, still-reserved codeId \u2014 a promotion with a per-line\n // benefit can have several `CartPromotionApplication` rows sharing the same `codeId`\n // (one per target line), so this dedupes rather than sending duplicates to `evaluate()`.\n const codesByCodeId = new Map<string, CodeRef>()\n const codeLinkageByPromotion = new Map<string, CodeLinkage>()\n for (const application of existingApplications) {\n if (!application.codeId) continue\n if (!lapsedCodeIds.has(application.codeId)) {\n codesByCodeId.set(application.codeId, {\n id: application.codeId,\n type: (application.codeType ?? 'static') as 'static' | 'dynamic',\n })\n }\n // Step 7.2 \u2014 captured regardless of lapsed status: a lapsed code's OWN rows are\n // removed below (never re-created), so there is nothing to carry linkage onto for it;\n // harmless to still index it here since `appliedPromotionIds` can never include a\n // promotion whose only code just lapsed and was excluded from `codes` above.\n if (!codeLinkageByPromotion.has(application.promotionId)) {\n codeLinkageByPromotion.set(application.promotionId, {\n code: application.code ?? null,\n codeId: application.codeId,\n codeType: application.codeType ?? null,\n codeReservationId: application.codeReservationId ?? null,\n })\n }\n }\n const codes = Array.from(codesByCodeId.values())\n\n // \u00A74.2/\u00A76a.2: a flagged line is excluded from evaluation. Gift lines are evaluator\n // OUTPUT, not input \u2014 re-evaluating them as purchasable items would double-count.\n const evaluableLines = lines.filter((line) => line.flagState == null && line.origin !== 'promotion')\n const buyer: BuyerContext = {\n customerId: cart.customerId ?? null,\n customerIds: cart.customerIds ?? [],\n customerGroupIds: cart.customerGroupIds,\n }\n\n const result = await promotionsService.evaluate({\n items: evaluableLines.map(toPromotionCartLine),\n currency: cart.currencyCode,\n evaluatedAt: now.toISOString(),\n buyer,\n // Neither field has a resolvable source in `cart` yet (no order-history lookup, no\n // weight aggregation) \u2014 `null` is correct per the type, not a shortcut. Revisit if a\n // rule a test fixture exercises turns out to gate on either field.\n customerOrderCount: null,\n cartWeight: null,\n codes,\n })\n\n // ---- Pure in-memory classification of the result (no `em` access yet) ----\n const lineById = new Map(lines.map((line) => [line.id, line]))\n const lineDiscountTargets: Array<{ promotionId: string; line: CartLine; effect: LineDiscountEffect }> = []\n const cartDiscountEntries: Array<{ promotionId: string; effect: CartDiscountEffect }> = []\n const freeItemEffects: Array<{ promotionId: string; effect: AddFreeItemEffect }> = []\n\n for (const applied of result.appliedPromotions) {\n for (const effect of applied.effects) {\n if (effect.type === 'LINE_DISCOUNT') {\n const target = lineById.get(effect.targetLineId)\n if (target) lineDiscountTargets.push({ promotionId: applied.promotionId, line: target, effect })\n } else if (effect.type === 'CART_DISCOUNT') {\n cartDiscountEntries.push({ promotionId: applied.promotionId, effect })\n } else if (effect.type === 'ADD_FREE_ITEM') {\n freeItemEffects.push({ promotionId: applied.promotionId, effect })\n }\n // `DELIVERY_DISCOUNT` \u2014 dropped (D-R4-14/\u00A76): never mapped to anything.\n }\n }\n\n const existingGiftLines = lines.filter((line) => line.origin === 'promotion')\n const existingGiftByKey = new Map(\n existingGiftLines.map((line) => [giftLineKey(line.promotionId as string, line.productId, line.variantId ?? null), line]),\n )\n const keepKeys = new Set(\n freeItemEffects.map(({ promotionId, effect }) => giftLineKey(promotionId, effect.productId, effect.variantId ?? null)),\n )\n const giftLinesToRemove = Array.from(existingGiftByKey.entries())\n .filter(([key]) => !keepKeys.has(key))\n .map(([, line]) => line)\n const newFreeItemEffects = freeItemEffects.filter(\n ({ promotionId, effect }) => !existingGiftByKey.has(giftLineKey(promotionId, effect.productId, effect.variantId ?? null)),\n )\n\n // ---- READ section part 2: every remaining `em` query this phase needs, before any\n // mutation below ----\n let visibility: CartVisibilityResult = new Map()\n let productsById = new Map<string, CatalogProduct>()\n let variantsById = new Map<string, CatalogProductVariant>()\n let nextLineNumber = 0\n if (newFreeItemEffects.length > 0) {\n const visibilityRefs = newFreeItemEffects.map(({ effect }) => ({ productId: effect.productId, variantId: effect.variantId ?? null }))\n visibility = await evaluateCartVisibility({ lines: visibilityRefs, scope: assortmentScope, tenantScope, em, container })\n\n const productIds = Array.from(new Set(newFreeItemEffects.map(({ effect }) => effect.productId)))\n const products = (await findWithDecryption(\n em,\n CatalogProduct,\n { id: { $in: productIds }, tenantId: tenantScope.tenantId, organizationId: tenantScope.organizationId, deletedAt: null },\n {},\n tenantScope,\n )) as CatalogProduct[]\n productsById = new Map(products.map((product) => [product.id, product]))\n\n const variantIds = Array.from(\n new Set(newFreeItemEffects.map(({ effect }) => effect.variantId).filter((id): id is string => !!id)),\n )\n if (variantIds.length > 0) {\n const variants = (await findWithDecryption(\n em,\n CatalogProductVariant,\n { id: { $in: variantIds }, tenantId: tenantScope.tenantId, organizationId: tenantScope.organizationId, deletedAt: null },\n {},\n tenantScope,\n )) as CatalogProductVariant[]\n variantsById = new Map(variants.map((variant) => [variant.id, variant]))\n }\n\n // Mirrors `commands/lines.ts`'s `computeStartingLineNumber` exactly (plain `em.find`,\n // no decryption wrapper \u2014 `CartLine` carries no encrypted columns): includes\n // soft-deleted lines too, so a freshly minted gift line never reuses a retired number.\n const allLinesForNumbering = await em.find(CartLine, {\n cartId: cart.id,\n tenantId: tenantScope.tenantId,\n organizationId: tenantScope.organizationId,\n })\n nextLineNumber = allLinesForNumbering.reduce((max, line) => Math.max(max, line.lineNumber), 0) + 1\n }\n\n // ---- MUTATE section: no more `em` reads past this point in this phase ----\n // Step 8.2 (QA F12) \u2014 every evaluated line's discount is re-derived from THIS\n // evaluation, not merged with its previous one: a line that dropped out of\n // `lineDiscountTargets` since the last evaluation (e.g. its quantity just fell below the\n // promotion's own \"2+ items\" threshold) must lose its stale `discountTotalNet`, not keep\n // whatever a PRIOR evaluation last wrote. Clearing every `evaluableLine` first, then\n // reapplying below for whatever is still targeted, makes that hold unconditionally\n // without a second lookup of which lines changed.\n for (const line of evaluableLines) {\n if (line.discountTotalNet !== null) line.discountTotalNet = null\n }\n for (const { line, effect } of lineDiscountTargets) {\n line.discountTotalNet = effect.amountNet\n }\n\n for (const giftLine of giftLinesToRemove) {\n giftLine.deletedAt = now\n const index = lines.indexOf(giftLine)\n if (index >= 0) lines.splice(index, 1)\n removedGiftLines.push(giftLine)\n }\n\n const createdGiftLineByKey = new Map<string, CartLine>()\n for (const { promotionId, effect } of newFreeItemEffects) {\n const ref = { productId: effect.productId, variantId: effect.variantId ?? null }\n const visible = isVisible(visibility.get(cartVisibilityKey(ref)) ?? 'unavailable')\n if (!visible) {\n warnings.push({ code: 'product_unavailable', productId: effect.productId, variantId: effect.variantId ?? null })\n continue\n }\n const product = productsById.get(effect.productId) ?? null\n if (!product) {\n // \u00A76a.3: a restricted product and a missing one collapse to the same outcome.\n warnings.push({ code: 'product_unavailable', productId: effect.productId, variantId: effect.variantId ?? null })\n continue\n }\n const variant = effect.variantId ? variantsById.get(effect.variantId) ?? null : null\n\n // \u00A74.2: `lineTotals` is \"verbatim SalesLineCalculationResult\" for every line, free\n // gift included \u2014 never a placeholder `{}` (that would ship a line whose own total\n // snapshot lies about having one). All-zero amounts in, all-zero amounts out.\n const giftLineTotals = await salesCalculationService.calculateLine({\n documentKind: 'quote',\n line: {\n kind: 'product',\n productId: product.id,\n productVariantId: variant?.id ?? null,\n name: product.title,\n quantity: effect.quantity,\n currencyCode: cart.currencyCode,\n unitPriceNet: 0,\n unitPriceGross: 0,\n taxRate: null,\n configuration: null,\n },\n context: { tenantId: cart.tenantId, organizationId: cart.organizationId, currencyCode: cart.currencyCode },\n })\n\n const giftLine = em.create(CartLine, {\n organizationId: cart.organizationId,\n tenantId: cart.tenantId,\n cartId: cart.id,\n lineNumber: nextLineNumber,\n productId: product.id,\n variantId: variant?.id ?? null,\n sku: variant?.sku ?? product.sku ?? null,\n name: product.title,\n optionValues: variant?.optionValues ?? null,\n quantity: String(effect.quantity),\n unitPriceNet: '0',\n unitPriceGross: '0',\n taxRate: null,\n origin: 'promotion' as const,\n promotionId,\n discountTotalNet: null,\n flagState: null,\n configuration: null,\n addedByActorType: 'system' as const,\n addedByActorId: null,\n metadata: null,\n pricedAt: now,\n lineTotals: giftLineTotals as unknown as Record<string, unknown>,\n })\n nextLineNumber += 1\n em.persist(giftLine)\n lines.push(giftLine)\n createdGiftLines.push(giftLine)\n createdGiftLineByKey.set(giftLineKey(promotionId, effect.productId, effect.variantId ?? null), giftLine)\n }\n\n // ---- Build the (promotionId -> applications) map now that every line mutation above\n // has resolved (gift lines created, discounts applied) \u2014 pure in-memory, no `em` access.\n const applicationsByPromotion = new Map<string, ApplicationEntry[]>()\n const pushApplication = (promotionId: string, entry: ApplicationEntry): void => {\n const entries = applicationsByPromotion.get(promotionId) ?? []\n entries.push(entry)\n applicationsByPromotion.set(promotionId, entries)\n }\n\n for (const { promotionId, line, effect } of lineDiscountTargets) {\n pushApplication(promotionId, { cartLineId: line.id, effect, amountNet: effect.amountNet, amountGross: effect.amountGross })\n }\n for (const { promotionId, effect } of cartDiscountEntries) {\n adjustments.push({\n scope: 'order',\n kind: 'discount',\n promotionId,\n amountNet: Number(effect.amountNet),\n amountGross: Number(effect.amountGross),\n currencyCode: effect.currency,\n label: pickEffectLabel(effect.label, cart.locale),\n })\n pushApplication(promotionId, { cartLineId: null, effect, amountNet: effect.amountNet, amountGross: effect.amountGross })\n }\n for (const { promotionId, effect } of newFreeItemEffects) {\n const createdLine = createdGiftLineByKey.get(giftLineKey(promotionId, effect.productId, effect.variantId ?? null))\n // A rejected gift (visibility/missing product) never got a line \u2014 no application row.\n if (!createdLine) continue\n pushApplication(promotionId, { cartLineId: createdLine.id, effect, amountNet: null, amountGross: null })\n }\n\n // ---- Sync `CartPromotionApplication` rows to the current evaluation (mutations only:\n // `existingApplications` was already fetched in the READ section above) ----\n //\n // Step 7.2 \u2014 THE KEY DESIGN DECISION (documented here, not just in the run's own\n // notes, since this is the single most load-bearing piece of reasoning in this phase):\n // `CartPromotionApplication.promotionId` is NOT NULL, and Step 7.1's original sync\n // logic removed ANY row whose `promotionId` was absent from this evaluation's\n // `appliedPromotions` \u2014 correct for an AUTOMATIC (codeless) promotion, where \"absent\n // from the result\" really does mean \"no longer applies, forget it.\" It is WRONG for a\n // CODE-bearing row: a code that reserves successfully but whose promotion's rule tree\n // does not currently match the cart's composition (e.g. a minimum-cart-value rule) also\n // comes back absent from `appliedPromotions` \u2014 not because the code was removed, but\n // because the rule simply does not match RIGHT NOW. Deleting that row on the very first\n // re-price after applying the code would erase the only record the code was ever\n // entered; the next re-price would feed `evaluate()` a `codes` list with nothing left to\n // rebuild it from, and the code is lost even though the buyer never removed it and the\n // reservation is still live.\n //\n // The fix: a row with a non-null `codeId` is \"an entered code,\" not merely \"a currently-\n // active discount.\" It is removed ONLY by (a) this phase's own lapsed-reservation check\n // above (the code itself is gone), or (b) an explicit `cart.promotions.remove`\n // (`commands/promotions.ts`) \u2014 never by this automatic sync just because its promotion\n // produced no effect THIS cycle. When that happens, the row survives with its effect\n // zeroed out (`effect: {}`, `amountNet`/`amountGross`: null, `cartLineId: null` \u2014 it no\n // longer points at a specific line because it is not discounting one) rather than being\n // deleted. A codeless (automatic) row keeps Step 7.1's exact original behavior: pruned\n // whenever its promotion is absent from the result.\n //\n // Removal is otherwise still keyed on (promotionId, cartLineId), not promotionId alone:\n // a promotion that stays applied but whose effect now targets a different line (or\n // switched which product it gifts) must drop its OLD row, not just leave it orphaned\n // pointing at a line that is no longer part of this evaluation \u2014 a FRESH row for the\n // same promotion is created in the loop below, with its code linkage (if any) carried\n // over via `codeLinkageByPromotion` so a promotion's code binding survives its effect\n // moving between lines.\n const appliedPromotionIds = new Set(result.appliedPromotions.map((applied) => applied.promotionId))\n const keepApplicationKeys = new Set<string>()\n for (const [promotionId, entries] of applicationsByPromotion) {\n for (const entry of entries) {\n keepApplicationKeys.add(`${promotionId}:${entry.cartLineId ?? ''}`)\n }\n }\n for (const existingApplication of existingApplications) {\n const codeId = existingApplication.codeId ?? null\n\n if (codeId != null && lapsedCodeIds.has(codeId)) {\n // The reservation backing this entered code is gone (expired, or released by a\n // promotions-side sweep) \u2014 D-R4-8's \"emits `cart.promotion.no_longer_valid` plus a\n // warning when a reservation lapses\". Removed unconditionally, even if this\n // promotion happens to still match THIS evaluation via some other, code-independent\n // rule \u2014 the ENTERED CODE itself is gone either way.\n em.remove(existingApplication)\n warnings.push({ code: 'promotion_no_longer_valid', promotionId: existingApplication.promotionId })\n continue\n }\n\n const key = `${existingApplication.promotionId}:${existingApplication.cartLineId ?? ''}`\n if (appliedPromotionIds.has(existingApplication.promotionId) && keepApplicationKeys.has(key)) {\n // Still current \u2014 the update loop below will refresh its amounts in place.\n continue\n }\n\n if (!appliedPromotionIds.has(existingApplication.promotionId) && codeId != null) {\n // THE KEY DESIGN DECISION above: keep the entered-code row, zero its effect.\n existingApplication.effect = {} as Record<string, unknown>\n existingApplication.amountNet = null\n existingApplication.amountGross = null\n existingApplication.cartLineId = null\n continue\n }\n\n em.remove(existingApplication)\n }\n\n for (const [promotionId, entries] of applicationsByPromotion) {\n for (const entry of entries) {\n const matched = existingApplications.find(\n (application) =>\n application.promotionId === promotionId && (application.cartLineId ?? null) === entry.cartLineId,\n )\n if (matched) {\n matched.amountNet = entry.amountNet\n matched.amountGross = entry.amountGross\n matched.effect = entry.effect as unknown as Record<string, unknown>\n matched.appliedAt = now\n continue\n }\n // Step 7.2 \u2014 a NEW row for a promotion that already had a code-bearing row under a\n // DIFFERENT key (e.g. its `LINE_DISCOUNT` target moved to another line) carries that\n // code's linkage forward; a promotion with no held code gets `NO_CODE_LINKAGE`,\n // exactly Step 7.1's original behavior.\n const linkage = codeLinkageByPromotion.get(promotionId) ?? NO_CODE_LINKAGE\n const application = em.create(CartPromotionApplication, {\n organizationId: cart.organizationId,\n tenantId: cart.tenantId,\n cartId: cart.id,\n promotionId,\n code: linkage.code,\n codeId: linkage.codeId,\n codeType: linkage.codeType,\n codeReservationId: linkage.codeReservationId,\n scope: entry.cartLineId ? ('line' as const) : ('order' as const),\n cartLineId: entry.cartLineId,\n effect: entry.effect as unknown as Record<string, unknown>,\n amountNet: entry.amountNet,\n amountGross: entry.amountGross,\n appliedAt: now,\n })\n em.persist(application)\n }\n }\n\n // \u00A74.1's own column exists but nothing reads it yet (checked `lib/cartView.ts` \u2014 no\n // `promotionEffects` consumer today beyond `commands/cart.ts`'s `toCartSummary` passing\n // it through verbatim). Stored raw rather than a trimmed projection so a future badge UI\n // has the full `appliedPromotions` shape to read from, same \"found and closed before\n // anyone needed it\" style this run's earlier steps use for a half-wired field.\n cart.promotionEffects = result.appliedPromotions\n })\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["application"]
7
7
  }
@@ -1,3 +1,4 @@
1
+ import { TransactionContext } from "@mikro-orm/core";
1
2
  import { computeProductScopeKeys, PRODUCT_SCOPE_CATEGORY_PREFIX } from "@open-mercato/core/modules/catalog/lib/productScopeKeys";
2
3
  import { Currency } from "@open-mercato/core/modules/currencies/data/entities";
3
4
  import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
@@ -19,7 +20,10 @@ function resolvePromotionsServiceScope(container) {
19
20
  return { tenantId, organizationId };
20
21
  }
21
22
  function resolvePromotionsEm(container) {
22
- return container.resolve("em").fork();
23
+ const registeredEm = container.resolve("em");
24
+ const contextualEm = TransactionContext.getEntityManager(registeredEm.name);
25
+ const em = contextualEm && contextualEm.getDriver() === registeredEm.getDriver() ? contextualEm : registeredEm;
26
+ return em.fork({ keepTransactionContext: true, disableContextResolution: Boolean(contextualEm && contextualEm !== em) });
23
27
  }
24
28
  function resolvePromotionsServiceAuthAndScope(container) {
25
29
  let auth = null;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/promotions/lib/promotions-service.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { computeProductScopeKeys, PRODUCT_SCOPE_CATEGORY_PREFIX } from '@open-mercato/core/modules/catalog/lib/productScopeKeys'\nimport { Currency } from '@open-mercato/core/modules/currencies/data/entities'\nimport type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { evaluatePromotions } from './evaluation-engine'\nimport { PromotionCodeService } from './code-service'\nimport type { PromotionCacheService } from '../services/promotion-cache'\nimport type {\n BuyerContext,\n CodeReservationResult,\n CodeUseResult,\n CodeValidationResult,\n EnrichedEvaluateItem,\n EvaluateInput,\n EvaluateItem,\n EvaluateResult,\n EvaluationInput,\n PromotionsService,\n RegisterUsageInput,\n ReserveCodeInput,\n RevertUsageInput,\n UseCodeInput,\n} from './types'\n\n// SPEC-055 \u00A7Implementation Plan Phase 2 step 6 / \u00A7Amendment A.2 \u2014 the DI facade named\n// `promotionsService`. Scope for Step 2.6 is `evaluate`/`evaluateForProduct` only; every\n// other `PromotionsService` method belongs to Phase 3 (codes) or its own usage-service\n// step and is a one-line throwing placeholder here, same convention as Step 2.1's\n// `evaluateGroup`/`resolveEffects` stubs in `rule-evaluators.ts`/`effect-resolvers.ts`.\n\nexport type PromotionsServiceContainer = {\n resolve: <T = unknown>(name: string) => T\n}\n\n// Minimal shape this file actually reads off whatever is registered under the `auth` DI\n// token. NOT an existing contract: see the long comment on `resolvePromotionsServiceScope`\n// below for why this is a documented gap, not a precedent this file is following.\n// `sub` is additive (Step 6.2): the five code-service methods below build a\n// `CommandRuntimeContext` to dispatch through `commandBus`, which needs a full-shaped\n// `auth` (actor id included) \u2014 `evaluate`/`evaluateForProduct` above never read it.\ntype PromotionsServiceAuthContext = {\n sub?: string\n tenantId?: string | null\n orgId?: string | null\n} | null | undefined\n\ntype PromotionsServiceScope = { tenantId: string; organizationId: string }\n\n// `EvaluateInput`/`evaluateForProduct`'s input (\u00A7Public TypeScript Contract) are frozen\n// with NO tenant/organization parameter \u2014 by design, D-R4-11/C3: \"no organizationId/\n// tenantId in any request body ... organizationId/tenantId come from auth.\" Every OTHER\n// place in this module that needs the caller's scope receives it explicitly: HTTP routes\n// build a `CommandRuntimeContext` with `auth`/`selectedOrganizationId` straight from\n// `getAuthFromRequest(req)` (see `api/order/route.ts`'s `resolveRouteContext`), and\n// commands read `ctx.auth?.tenantId` / `ctx.selectedOrganizationId ?? ctx.auth?.orgId`\n// (see `commands/promotions.ts`'s `resolveActorScope`) \u2014 never from the DI container.\n//\n// Investigated and NOT found: `createRequestContainer()` (`packages/shared/src/lib/di/\n// container.ts`) never registers an `auth` token, and no module in this codebase\n// registers one either \u2014 every grep for a DI-registered (not per-request-handler)\n// service that resolves the current request's auth/tenant scope from the container\n// alone, with no explicit parameter, came back empty. `PromotionCacheService` (the\n// established sibling in this same module) sidesteps the question entirely: its public\n// methods take `tenantId`/`organizationId` as explicit arguments, exactly because there\n// is nothing to resolve implicitly.\n//\n// `evaluate`/`evaluateForProduct`'s signatures are frozen and take no second parameter,\n// so resolving scope from the container is the only option left that does not violate\n// the contract freeze. This resolves `auth` off the container (the shape the Amendment's\n// \"DI service, not HTTP\" framing implies a future `cart` caller will register before\n// resolving `promotionsService`) and fails closed with a clear 400 when it is absent \u2014\n// but it is new, not an existing pattern: a hard, in-process caller (eventually `cart`)\n// MUST `container.register({ auth: asValue(auth) })` on its own per-request container\n// before calling `promotionsService.evaluate()`, the same way it already carries `em`.\nfunction resolvePromotionsServiceScope(container: PromotionsServiceContainer): PromotionsServiceScope {\n let auth: PromotionsServiceAuthContext = null\n try {\n auth = container.resolve<PromotionsServiceAuthContext>('auth')\n } catch {\n auth = null\n }\n const tenantId = auth?.tenantId ?? null\n const organizationId = auth?.orgId ?? null\n if (!tenantId || !organizationId) {\n throw new CrudHttpError(400, {\n error: '[internal] Tenant and organization scope are required to evaluate promotions',\n })\n }\n return { tenantId, organizationId }\n}\n\nfunction resolvePromotionsEm(container: PromotionsServiceContainer): EntityManager {\n // Same fork-on-resolve precedent as `services/promotion-cache.ts`'s\n // `resolvePromotionEm` / `commands/promotions.ts`'s command handlers.\n return container.resolve<EntityManager>('em').fork()\n}\n\n// Step 6.2's five code-service methods need the full `auth` (not just tenant/org scope)\n// to build the `CommandRuntimeContext` `commandBus.execute()` requires for\n// `reserveCode`/`releaseCode`/`useCode` (\u00A7A.2a: registered commands). Deliberately\n// separate from `resolvePromotionsServiceScope` above (untouched \u2014 `evaluate`/\n// `evaluateForProduct` only ever need the two scope ids, never a command context).\nfunction resolvePromotionsServiceAuthAndScope(container: PromotionsServiceContainer): {\n auth: NonNullable<PromotionsServiceAuthContext>\n scope: PromotionsServiceScope\n} {\n let auth: PromotionsServiceAuthContext = null\n try {\n auth = container.resolve<PromotionsServiceAuthContext>('auth')\n } catch {\n auth = null\n }\n const tenantId = auth?.tenantId ?? null\n const organizationId = auth?.orgId ?? null\n if (!auth || !tenantId || !organizationId) {\n throw new CrudHttpError(400, {\n error: '[internal] Tenant and organization scope are required for a promotion code operation',\n })\n }\n return { auth, scope: { tenantId, organizationId } }\n}\n\n// Minimal `CommandRuntimeContext` for an in-process, non-HTTP caller of this facade \u2014\n// same shape `translations/api/context.ts`'s route-edge builder produces, minus the\n// request-specific fields no module/worker caller has. `organizationScope: null` makes\n// `ensureOrganizationScope`/`ensureTenantScope` (called inside `commands/codes.ts`) fall\n// back to comparing against `selectedOrganizationId`/`auth.orgId` directly \u2014 exactly the\n// scope this function already resolved, so the command's own scope assertion always\n// passes for a legitimately-scoped caller.\nfunction buildPromotionsCommandContext(\n container: PromotionsServiceContainer,\n auth: NonNullable<PromotionsServiceAuthContext>,\n scope: PromotionsServiceScope,\n): CommandRuntimeContext {\n return {\n container: container as unknown as CommandRuntimeContext['container'],\n auth: auth as unknown as CommandRuntimeContext['auth'],\n organizationScope: null,\n selectedOrganizationId: scope.organizationId,\n organizationIds: [scope.organizationId],\n }\n}\n\nfunction resolvePromotionsCommandBus(container: PromotionsServiceContainer): CommandBus {\n return container.resolve<CommandBus>('commandBus')\n}\n\nfunction resolvePromotionCodeService(container: PromotionsServiceContainer): PromotionCodeService {\n return container.resolve<PromotionCodeService>('promotionCodeService')\n}\n\n// D-R4-21/C11 batch category resolution \u2014 reuses `catalog`'s own `computeProductScopeKeys`\n// exactly (the same function catalog's search-index enricher uses for \"category\n// membership including ancestors\"), then narrows its combined `cat:<id>`/`tag:<id>` key\n// set down to the plain, ancestor-expanded category id array `EnrichedEvaluateItem`\n// expects (`rule-evaluators.ts` checks `item.categoryIds.includes(categoryId)` against\n// bare ids, never prefixed keys).\nasync function resolveCategoryIdsForProducts(\n container: PromotionsServiceContainer,\n productIds: readonly string[],\n scope: PromotionsServiceScope,\n): Promise<Map<string, string[]>> {\n const result = new Map<string, string[]>()\n if (productIds.length === 0) return result\n const em = resolvePromotionsEm(container)\n const db = em.getKysely<any>()\n const scopeKeys = await computeProductScopeKeys(db, productIds, scope)\n for (const [productId, keys] of scopeKeys) {\n const categoryIds = keys\n .filter((key) => key.startsWith(PRODUCT_SCOPE_CATEGORY_PREFIX))\n .map((key) => key.slice(PRODUCT_SCOPE_CATEGORY_PREFIX.length))\n result.set(productId, categoryIds)\n }\n return result\n}\n\n// Investigated and NOT found: a batch \"attribute code -> value\" read path for\n// `catalog:catalog_product`. `catalog/ce.ts` ships zero built-in custom fields for that\n// entity (`fields: []`) \u2014 attributes are purely per-tenant, dynamically defined custom\n// fields with no \"attribute code\" concept distinct from any other custom field, so there\n// is no stable registry this file could batch-read against without speculating about a\n// shape nothing in the codebase defines yet. Falls back to `{}` per D-R4-21's own rule:\n// \"a field with no resolvable source for a given product is [...] an empty [...] object,\n// never undefined\" (`lib/types.ts`'s `EnrichedEvaluateItem` comment).\nfunction resolveAttributesForProduct(_productId: string): Record<string, string> {\n return {}\n}\n\nfunction buildEnrichedItems(\n items: EvaluateItem[],\n categoryIdsByProduct: Map<string, string[]>,\n): EnrichedEvaluateItem[] {\n return items.map((item) => ({\n ...item,\n categoryIds: categoryIdsByProduct.get(item.productId) ?? [],\n attributes: resolveAttributesForProduct(item.productId),\n }))\n}\n\nasync function enrichEvaluateItems(\n container: PromotionsServiceContainer,\n items: EvaluateItem[],\n scope: PromotionsServiceScope,\n): Promise<EnrichedEvaluateItem[]> {\n const productIds = Array.from(new Set(items.map((item) => item.productId)))\n const categoryIdsByProduct = await resolveCategoryIdsForProducts(container, productIds, scope)\n return buildEnrichedItems(items, categoryIdsByProduct)\n}\n\n// D-R4-10 / Step 2.3b's own deferred TODO, closed here: `lib/effect-resolvers.ts`\n// defaults to 2 fraction digits when `EvaluationContext.currencyFractionDigits` is\n// absent, documenting that a later step (this one) resolves the real value from\n// `currencies.Currency.decimalPlaces`. Best-effort: a lookup failure (currency not\n// found, DB error) silently keeps the pre-existing default-2 behavior rather than\n// failing the whole evaluation over a rounding-precision lookup.\nasync function resolveCurrencyFractionDigits(\n container: PromotionsServiceContainer,\n currency: string,\n scope: PromotionsServiceScope,\n): Promise<number | undefined> {\n if (!currency) return undefined\n try {\n const em = resolvePromotionsEm(container)\n const row = await em.findOne(Currency, {\n code: currency,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n })\n return row?.decimalPlaces ?? undefined\n } catch {\n return undefined\n }\n}\n\nfunction buildEvaluateForProductItem(input: {\n productId: string\n variantId: string | null\n categoryIds: string[]\n}): EnrichedEvaluateItem {\n return {\n lineId: 'evaluate-for-product',\n productId: input.productId,\n variantId: input.variantId,\n quantity: '1',\n // No real price data is available for a single-product badge check \u2014 this call\n // shape only needs rule matching, never discount computation, so these are inert.\n unitPriceNet: '0',\n unitPriceGross: '0',\n priceIsPromotional: false,\n categoryIds: input.categoryIds,\n attributes: resolveAttributesForProduct(input.productId),\n }\n}\n\nexport class PromotionsServiceImpl implements PromotionsService {\n constructor(private readonly container: PromotionsServiceContainer) {}\n\n async evaluate(input: EvaluateInput): Promise<EvaluateResult> {\n const scope = resolvePromotionsServiceScope(this.container)\n const promotionCacheService = this.container.resolve<PromotionCacheService>('promotionCacheService')\n const promotions = await promotionCacheService.getActivePromotions(scope.tenantId, scope.organizationId)\n\n const items = await enrichEvaluateItems(this.container, input.items, scope)\n const currencyFractionDigits = await resolveCurrencyFractionDigits(this.container, input.currency, scope)\n\n // `input.buyer` is already a fully-formed `BuyerContext` by the time it reaches this\n // facade (\u00A7Public TypeScript Contract: `EvaluateInput.buyer: BuyerContext`) \u2014 D-R4-28\n // cites D3/D3a (person-wins union identity) only to explain WHY the caller's\n // `BuyerContext` is shaped the way it is, not as work this method performs. The\n // caller (eventually `cart`, reading fresh from the DB per D3) resolves identity;\n // this facade passes it straight through into the pure engine's `EvaluationInput`.\n const evaluationInput: EvaluationInput = {\n items,\n currency: input.currency,\n evaluatedAt: input.evaluatedAt,\n buyer: input.buyer,\n customerOrderCount: input.customerOrderCount,\n cartWeight: input.cartWeight,\n codes: input.codes,\n ...(currencyFractionDigits !== undefined ? { currencyFractionDigits } : {}),\n }\n\n return evaluatePromotions(promotions, evaluationInput)\n }\n\n async evaluateForProduct(input: {\n productId: string\n variantId: string | null\n buyer: BuyerContext\n evaluatedAt: string\n }): Promise<{ badges: Array<{ promotionId: string; promotionName: string; label: Record<string, string> }> }> {\n const scope = resolvePromotionsServiceScope(this.container)\n const promotionCacheService = this.container.resolve<PromotionCacheService>('promotionCacheService')\n const promotions = await promotionCacheService.getActivePromotions(scope.tenantId, scope.organizationId)\n\n const categoryIdsByProduct = await resolveCategoryIdsForProducts(this.container, [input.productId], scope)\n const item = buildEvaluateForProductItem({\n productId: input.productId,\n variantId: input.variantId,\n categoryIds: categoryIdsByProduct.get(input.productId) ?? [],\n })\n\n // Deliberately no real `currency` \u2014 a single-product badge check has none to supply\n // and the type signature carries none. The one observable consequence: a promotion\n // that restricts `eligibleCurrencies` never badges here, the same fail-closed\n // behavior `isPromotionEligible` already applies to a currency it does not\n // recognize. `customerOrderCount`/`cartWeight` are `null` for the same reason \u2014\n // there is no real cart behind this call, so any rule reading them evaluates false\n // rather than guessing a cart that does not exist.\n const evaluationInput: EvaluationInput = {\n items: [item],\n currency: '',\n evaluatedAt: input.evaluatedAt,\n buyer: input.buyer,\n customerOrderCount: null,\n cartWeight: null,\n codes: [],\n }\n\n const result = evaluatePromotions(promotions, evaluationInput)\n return {\n badges: result.appliedPromotions.map((applied) => ({\n promotionId: applied.promotionId,\n promotionName: applied.promotionName,\n // A promotion may resolve several effects (e.g. a capped benefit covering\n // several lines); a single-product badge shows one label, so the first\n // resolved effect's label wins \u2014 documented judgment call, not a spec rule.\n label: applied.effects[0]?.label ?? {},\n })),\n }\n }\n\n // \u00A7A.2a: a registered command (`promotions.code.reserve`) \u2014 \"financially or budget\n // consequential\" per root AGENTS.md's command-pattern rule.\n async reserveCode(input: ReserveCodeInput): Promise<CodeReservationResult> {\n const { auth, scope } = resolvePromotionsServiceAuthAndScope(this.container)\n const commandBus = resolvePromotionsCommandBus(this.container)\n const ctx = buildPromotionsCommandContext(this.container, auth, scope)\n const { result } = await commandBus.execute<ReserveCodeInput, CodeReservationResult>(\n 'promotions.code.reserve',\n { input, ctx },\n )\n return result\n }\n\n // \u00A7A.2a: plain housekeeping, never command-wrapped \u2014 bumps `CodeReservation.expiresAt`\n // directly through `PromotionCodeService`.\n async extendReservation(codeId: string, cartId: string): Promise<void> {\n const { scope } = resolvePromotionsServiceAuthAndScope(this.container)\n const em = resolvePromotionsEm(this.container)\n await resolvePromotionCodeService(this.container).extendReservation(em, codeId, cartId, scope)\n }\n\n // \u00A7A.2a: a plain read (no mutation) \u2014 never command-wrapped.\n async validateCode(input: ReserveCodeInput): Promise<CodeValidationResult> {\n const { scope } = resolvePromotionsServiceAuthAndScope(this.container)\n const em = resolvePromotionsEm(this.container)\n return resolvePromotionCodeService(this.container).validateCode(em, input, scope)\n }\n\n // \u00A7A.2a: a registered command (`promotions.code.use`).\n async useCode(input: UseCodeInput): Promise<CodeUseResult> {\n const { auth, scope } = resolvePromotionsServiceAuthAndScope(this.container)\n const commandBus = resolvePromotionsCommandBus(this.container)\n const ctx = buildPromotionsCommandContext(this.container, auth, scope)\n const { result } = await commandBus.execute<UseCodeInput, CodeUseResult>(\n 'promotions.code.use',\n { input, ctx },\n )\n return result\n }\n\n // \u00A7A.2a: a registered command (`promotions.code.release`). The public signature\n // returns `void` (\u00A7Public TypeScript Contract) \u2014 the command's own `{ ok: true }`\n // result is intentionally discarded here, not re-shaped into the return type.\n async releaseCode(codeId: string, cartId: string): Promise<void> {\n const { auth, scope } = resolvePromotionsServiceAuthAndScope(this.container)\n const commandBus = resolvePromotionsCommandBus(this.container)\n const ctx = buildPromotionsCommandContext(this.container, auth, scope)\n await commandBus.execute<{ codeId: string; cartId: string }, { ok: true }>(\n 'promotions.code.release',\n { input: { codeId, cartId }, ctx },\n )\n }\n\n // \u00A7A.2a: a registered command (`promotions.usage.register`) \u2014 \"financially or budget\n // consequential\" per root AGENTS.md's command-pattern rule, same as the five code-\n // service methods above.\n async registerUsage(input: RegisterUsageInput): Promise<{ ok: true; budgetExceeded: string[] }> {\n const { auth, scope } = resolvePromotionsServiceAuthAndScope(this.container)\n const commandBus = resolvePromotionsCommandBus(this.container)\n const ctx = buildPromotionsCommandContext(this.container, auth, scope)\n const { result } = await commandBus.execute<RegisterUsageInput, { ok: true; budgetExceeded: string[] }>(\n 'promotions.usage.register',\n { input, ctx },\n )\n return result\n }\n\n // \u00A7A.2a: a registered command (`promotions.usage.revert`).\n async revertUsage(input: RevertUsageInput): Promise<{ ok: true; revertedCount: number }> {\n const { auth, scope } = resolvePromotionsServiceAuthAndScope(this.container)\n const commandBus = resolvePromotionsCommandBus(this.container)\n const ctx = buildPromotionsCommandContext(this.container, auth, scope)\n const { result } = await commandBus.execute<RevertUsageInput, { ok: true; revertedCount: number }>(\n 'promotions.usage.revert',\n { input, ctx },\n )\n return result\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { TransactionContext } from '@mikro-orm/core'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { computeProductScopeKeys, PRODUCT_SCOPE_CATEGORY_PREFIX } from '@open-mercato/core/modules/catalog/lib/productScopeKeys'\nimport { Currency } from '@open-mercato/core/modules/currencies/data/entities'\nimport type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { evaluatePromotions } from './evaluation-engine'\nimport { PromotionCodeService } from './code-service'\nimport type { PromotionCacheService } from '../services/promotion-cache'\nimport type {\n BuyerContext,\n CodeReservationResult,\n CodeUseResult,\n CodeValidationResult,\n EnrichedEvaluateItem,\n EvaluateInput,\n EvaluateItem,\n EvaluateResult,\n EvaluationInput,\n PromotionsService,\n RegisterUsageInput,\n ReserveCodeInput,\n RevertUsageInput,\n UseCodeInput,\n} from './types'\n\n// SPEC-055 \u00A7Implementation Plan Phase 2 step 6 / \u00A7Amendment A.2 \u2014 the DI facade named\n// `promotionsService`. Scope for Step 2.6 is `evaluate`/`evaluateForProduct` only; every\n// other `PromotionsService` method belongs to Phase 3 (codes) or its own usage-service\n// step and is a one-line throwing placeholder here, same convention as Step 2.1's\n// `evaluateGroup`/`resolveEffects` stubs in `rule-evaluators.ts`/`effect-resolvers.ts`.\n\nexport type PromotionsServiceContainer = {\n resolve: <T = unknown>(name: string) => T\n}\n\n// Minimal shape this file actually reads off whatever is registered under the `auth` DI\n// token. NOT an existing contract: see the long comment on `resolvePromotionsServiceScope`\n// below for why this is a documented gap, not a precedent this file is following.\n// `sub` is additive (Step 6.2): the five code-service methods below build a\n// `CommandRuntimeContext` to dispatch through `commandBus`, which needs a full-shaped\n// `auth` (actor id included) \u2014 `evaluate`/`evaluateForProduct` above never read it.\ntype PromotionsServiceAuthContext = {\n sub?: string\n tenantId?: string | null\n orgId?: string | null\n} | null | undefined\n\ntype PromotionsServiceScope = { tenantId: string; organizationId: string }\n\n// `EvaluateInput`/`evaluateForProduct`'s input (\u00A7Public TypeScript Contract) are frozen\n// with NO tenant/organization parameter \u2014 by design, D-R4-11/C3: \"no organizationId/\n// tenantId in any request body ... organizationId/tenantId come from auth.\" Every OTHER\n// place in this module that needs the caller's scope receives it explicitly: HTTP routes\n// build a `CommandRuntimeContext` with `auth`/`selectedOrganizationId` straight from\n// `getAuthFromRequest(req)` (see `api/order/route.ts`'s `resolveRouteContext`), and\n// commands read `ctx.auth?.tenantId` / `ctx.selectedOrganizationId ?? ctx.auth?.orgId`\n// (see `commands/promotions.ts`'s `resolveActorScope`) \u2014 never from the DI container.\n//\n// Investigated and NOT found: `createRequestContainer()` (`packages/shared/src/lib/di/\n// container.ts`) never registers an `auth` token, and no module in this codebase\n// registers one either \u2014 every grep for a DI-registered (not per-request-handler)\n// service that resolves the current request's auth/tenant scope from the container\n// alone, with no explicit parameter, came back empty. `PromotionCacheService` (the\n// established sibling in this same module) sidesteps the question entirely: its public\n// methods take `tenantId`/`organizationId` as explicit arguments, exactly because there\n// is nothing to resolve implicitly.\n//\n// `evaluate`/`evaluateForProduct`'s signatures are frozen and take no second parameter,\n// so resolving scope from the container is the only option left that does not violate\n// the contract freeze. This resolves `auth` off the container (the shape the Amendment's\n// \"DI service, not HTTP\" framing implies a future `cart` caller will register before\n// resolving `promotionsService`) and fails closed with a clear 400 when it is absent \u2014\n// but it is new, not an existing pattern: a hard, in-process caller (eventually `cart`)\n// MUST `container.register({ auth: asValue(auth) })` on its own per-request container\n// before calling `promotionsService.evaluate()`, the same way it already carries `em`.\nfunction resolvePromotionsServiceScope(container: PromotionsServiceContainer): PromotionsServiceScope {\n let auth: PromotionsServiceAuthContext = null\n try {\n auth = container.resolve<PromotionsServiceAuthContext>('auth')\n } catch {\n auth = null\n }\n const tenantId = auth?.tenantId ?? null\n const organizationId = auth?.orgId ?? null\n if (!tenantId || !organizationId) {\n throw new CrudHttpError(400, {\n error: '[internal] Tenant and organization scope are required to evaluate promotions',\n })\n }\n return { tenantId, organizationId }\n}\n\nfunction resolvePromotionsEm(container: PromotionsServiceContainer): EntityManager {\n const registeredEm = container.resolve<EntityManager>('em')\n const contextualEm = TransactionContext.getEntityManager(registeredEm.name) as EntityManager | undefined\n const em = contextualEm && contextualEm.getDriver() === registeredEm.getDriver() ? contextualEm : registeredEm\n return em.fork({ keepTransactionContext: true, disableContextResolution: Boolean(contextualEm && contextualEm !== em) })\n}\n\n// Step 6.2's five code-service methods need the full `auth` (not just tenant/org scope)\n// to build the `CommandRuntimeContext` `commandBus.execute()` requires for\n// `reserveCode`/`releaseCode`/`useCode` (\u00A7A.2a: registered commands). Deliberately\n// separate from `resolvePromotionsServiceScope` above (untouched \u2014 `evaluate`/\n// `evaluateForProduct` only ever need the two scope ids, never a command context).\nfunction resolvePromotionsServiceAuthAndScope(container: PromotionsServiceContainer): {\n auth: NonNullable<PromotionsServiceAuthContext>\n scope: PromotionsServiceScope\n} {\n let auth: PromotionsServiceAuthContext = null\n try {\n auth = container.resolve<PromotionsServiceAuthContext>('auth')\n } catch {\n auth = null\n }\n const tenantId = auth?.tenantId ?? null\n const organizationId = auth?.orgId ?? null\n if (!auth || !tenantId || !organizationId) {\n throw new CrudHttpError(400, {\n error: '[internal] Tenant and organization scope are required for a promotion code operation',\n })\n }\n return { auth, scope: { tenantId, organizationId } }\n}\n\n// Minimal `CommandRuntimeContext` for an in-process, non-HTTP caller of this facade \u2014\n// same shape `translations/api/context.ts`'s route-edge builder produces, minus the\n// request-specific fields no module/worker caller has. `organizationScope: null` makes\n// `ensureOrganizationScope`/`ensureTenantScope` (called inside `commands/codes.ts`) fall\n// back to comparing against `selectedOrganizationId`/`auth.orgId` directly \u2014 exactly the\n// scope this function already resolved, so the command's own scope assertion always\n// passes for a legitimately-scoped caller.\nfunction buildPromotionsCommandContext(\n container: PromotionsServiceContainer,\n auth: NonNullable<PromotionsServiceAuthContext>,\n scope: PromotionsServiceScope,\n): CommandRuntimeContext {\n return {\n container: container as unknown as CommandRuntimeContext['container'],\n auth: auth as unknown as CommandRuntimeContext['auth'],\n organizationScope: null,\n selectedOrganizationId: scope.organizationId,\n organizationIds: [scope.organizationId],\n }\n}\n\nfunction resolvePromotionsCommandBus(container: PromotionsServiceContainer): CommandBus {\n return container.resolve<CommandBus>('commandBus')\n}\n\nfunction resolvePromotionCodeService(container: PromotionsServiceContainer): PromotionCodeService {\n return container.resolve<PromotionCodeService>('promotionCodeService')\n}\n\n// D-R4-21/C11 batch category resolution \u2014 reuses `catalog`'s own `computeProductScopeKeys`\n// exactly (the same function catalog's search-index enricher uses for \"category\n// membership including ancestors\"), then narrows its combined `cat:<id>`/`tag:<id>` key\n// set down to the plain, ancestor-expanded category id array `EnrichedEvaluateItem`\n// expects (`rule-evaluators.ts` checks `item.categoryIds.includes(categoryId)` against\n// bare ids, never prefixed keys).\nasync function resolveCategoryIdsForProducts(\n container: PromotionsServiceContainer,\n productIds: readonly string[],\n scope: PromotionsServiceScope,\n): Promise<Map<string, string[]>> {\n const result = new Map<string, string[]>()\n if (productIds.length === 0) return result\n const em = resolvePromotionsEm(container)\n const db = em.getKysely<any>()\n const scopeKeys = await computeProductScopeKeys(db, productIds, scope)\n for (const [productId, keys] of scopeKeys) {\n const categoryIds = keys\n .filter((key) => key.startsWith(PRODUCT_SCOPE_CATEGORY_PREFIX))\n .map((key) => key.slice(PRODUCT_SCOPE_CATEGORY_PREFIX.length))\n result.set(productId, categoryIds)\n }\n return result\n}\n\n// Investigated and NOT found: a batch \"attribute code -> value\" read path for\n// `catalog:catalog_product`. `catalog/ce.ts` ships zero built-in custom fields for that\n// entity (`fields: []`) \u2014 attributes are purely per-tenant, dynamically defined custom\n// fields with no \"attribute code\" concept distinct from any other custom field, so there\n// is no stable registry this file could batch-read against without speculating about a\n// shape nothing in the codebase defines yet. Falls back to `{}` per D-R4-21's own rule:\n// \"a field with no resolvable source for a given product is [...] an empty [...] object,\n// never undefined\" (`lib/types.ts`'s `EnrichedEvaluateItem` comment).\nfunction resolveAttributesForProduct(_productId: string): Record<string, string> {\n return {}\n}\n\nfunction buildEnrichedItems(\n items: EvaluateItem[],\n categoryIdsByProduct: Map<string, string[]>,\n): EnrichedEvaluateItem[] {\n return items.map((item) => ({\n ...item,\n categoryIds: categoryIdsByProduct.get(item.productId) ?? [],\n attributes: resolveAttributesForProduct(item.productId),\n }))\n}\n\nasync function enrichEvaluateItems(\n container: PromotionsServiceContainer,\n items: EvaluateItem[],\n scope: PromotionsServiceScope,\n): Promise<EnrichedEvaluateItem[]> {\n const productIds = Array.from(new Set(items.map((item) => item.productId)))\n const categoryIdsByProduct = await resolveCategoryIdsForProducts(container, productIds, scope)\n return buildEnrichedItems(items, categoryIdsByProduct)\n}\n\n// D-R4-10 / Step 2.3b's own deferred TODO, closed here: `lib/effect-resolvers.ts`\n// defaults to 2 fraction digits when `EvaluationContext.currencyFractionDigits` is\n// absent, documenting that a later step (this one) resolves the real value from\n// `currencies.Currency.decimalPlaces`. Best-effort: a lookup failure (currency not\n// found, DB error) silently keeps the pre-existing default-2 behavior rather than\n// failing the whole evaluation over a rounding-precision lookup.\nasync function resolveCurrencyFractionDigits(\n container: PromotionsServiceContainer,\n currency: string,\n scope: PromotionsServiceScope,\n): Promise<number | undefined> {\n if (!currency) return undefined\n try {\n const em = resolvePromotionsEm(container)\n const row = await em.findOne(Currency, {\n code: currency,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n })\n return row?.decimalPlaces ?? undefined\n } catch {\n return undefined\n }\n}\n\nfunction buildEvaluateForProductItem(input: {\n productId: string\n variantId: string | null\n categoryIds: string[]\n}): EnrichedEvaluateItem {\n return {\n lineId: 'evaluate-for-product',\n productId: input.productId,\n variantId: input.variantId,\n quantity: '1',\n // No real price data is available for a single-product badge check \u2014 this call\n // shape only needs rule matching, never discount computation, so these are inert.\n unitPriceNet: '0',\n unitPriceGross: '0',\n priceIsPromotional: false,\n categoryIds: input.categoryIds,\n attributes: resolveAttributesForProduct(input.productId),\n }\n}\n\nexport class PromotionsServiceImpl implements PromotionsService {\n constructor(private readonly container: PromotionsServiceContainer) {}\n\n async evaluate(input: EvaluateInput): Promise<EvaluateResult> {\n const scope = resolvePromotionsServiceScope(this.container)\n const promotionCacheService = this.container.resolve<PromotionCacheService>('promotionCacheService')\n const promotions = await promotionCacheService.getActivePromotions(scope.tenantId, scope.organizationId)\n\n const items = await enrichEvaluateItems(this.container, input.items, scope)\n const currencyFractionDigits = await resolveCurrencyFractionDigits(this.container, input.currency, scope)\n\n // `input.buyer` is already a fully-formed `BuyerContext` by the time it reaches this\n // facade (\u00A7Public TypeScript Contract: `EvaluateInput.buyer: BuyerContext`) \u2014 D-R4-28\n // cites D3/D3a (person-wins union identity) only to explain WHY the caller's\n // `BuyerContext` is shaped the way it is, not as work this method performs. The\n // caller (eventually `cart`, reading fresh from the DB per D3) resolves identity;\n // this facade passes it straight through into the pure engine's `EvaluationInput`.\n const evaluationInput: EvaluationInput = {\n items,\n currency: input.currency,\n evaluatedAt: input.evaluatedAt,\n buyer: input.buyer,\n customerOrderCount: input.customerOrderCount,\n cartWeight: input.cartWeight,\n codes: input.codes,\n ...(currencyFractionDigits !== undefined ? { currencyFractionDigits } : {}),\n }\n\n return evaluatePromotions(promotions, evaluationInput)\n }\n\n async evaluateForProduct(input: {\n productId: string\n variantId: string | null\n buyer: BuyerContext\n evaluatedAt: string\n }): Promise<{ badges: Array<{ promotionId: string; promotionName: string; label: Record<string, string> }> }> {\n const scope = resolvePromotionsServiceScope(this.container)\n const promotionCacheService = this.container.resolve<PromotionCacheService>('promotionCacheService')\n const promotions = await promotionCacheService.getActivePromotions(scope.tenantId, scope.organizationId)\n\n const categoryIdsByProduct = await resolveCategoryIdsForProducts(this.container, [input.productId], scope)\n const item = buildEvaluateForProductItem({\n productId: input.productId,\n variantId: input.variantId,\n categoryIds: categoryIdsByProduct.get(input.productId) ?? [],\n })\n\n // Deliberately no real `currency` \u2014 a single-product badge check has none to supply\n // and the type signature carries none. The one observable consequence: a promotion\n // that restricts `eligibleCurrencies` never badges here, the same fail-closed\n // behavior `isPromotionEligible` already applies to a currency it does not\n // recognize. `customerOrderCount`/`cartWeight` are `null` for the same reason \u2014\n // there is no real cart behind this call, so any rule reading them evaluates false\n // rather than guessing a cart that does not exist.\n const evaluationInput: EvaluationInput = {\n items: [item],\n currency: '',\n evaluatedAt: input.evaluatedAt,\n buyer: input.buyer,\n customerOrderCount: null,\n cartWeight: null,\n codes: [],\n }\n\n const result = evaluatePromotions(promotions, evaluationInput)\n return {\n badges: result.appliedPromotions.map((applied) => ({\n promotionId: applied.promotionId,\n promotionName: applied.promotionName,\n // A promotion may resolve several effects (e.g. a capped benefit covering\n // several lines); a single-product badge shows one label, so the first\n // resolved effect's label wins \u2014 documented judgment call, not a spec rule.\n label: applied.effects[0]?.label ?? {},\n })),\n }\n }\n\n // \u00A7A.2a: a registered command (`promotions.code.reserve`) \u2014 \"financially or budget\n // consequential\" per root AGENTS.md's command-pattern rule.\n async reserveCode(input: ReserveCodeInput): Promise<CodeReservationResult> {\n const { auth, scope } = resolvePromotionsServiceAuthAndScope(this.container)\n const commandBus = resolvePromotionsCommandBus(this.container)\n const ctx = buildPromotionsCommandContext(this.container, auth, scope)\n const { result } = await commandBus.execute<ReserveCodeInput, CodeReservationResult>(\n 'promotions.code.reserve',\n { input, ctx },\n )\n return result\n }\n\n // \u00A7A.2a: plain housekeeping, never command-wrapped \u2014 bumps `CodeReservation.expiresAt`\n // directly through `PromotionCodeService`.\n async extendReservation(codeId: string, cartId: string): Promise<void> {\n const { scope } = resolvePromotionsServiceAuthAndScope(this.container)\n const em = resolvePromotionsEm(this.container)\n await resolvePromotionCodeService(this.container).extendReservation(em, codeId, cartId, scope)\n }\n\n // \u00A7A.2a: a plain read (no mutation) \u2014 never command-wrapped.\n async validateCode(input: ReserveCodeInput): Promise<CodeValidationResult> {\n const { scope } = resolvePromotionsServiceAuthAndScope(this.container)\n const em = resolvePromotionsEm(this.container)\n return resolvePromotionCodeService(this.container).validateCode(em, input, scope)\n }\n\n // \u00A7A.2a: a registered command (`promotions.code.use`).\n async useCode(input: UseCodeInput): Promise<CodeUseResult> {\n const { auth, scope } = resolvePromotionsServiceAuthAndScope(this.container)\n const commandBus = resolvePromotionsCommandBus(this.container)\n const ctx = buildPromotionsCommandContext(this.container, auth, scope)\n const { result } = await commandBus.execute<UseCodeInput, CodeUseResult>(\n 'promotions.code.use',\n { input, ctx },\n )\n return result\n }\n\n // \u00A7A.2a: a registered command (`promotions.code.release`). The public signature\n // returns `void` (\u00A7Public TypeScript Contract) \u2014 the command's own `{ ok: true }`\n // result is intentionally discarded here, not re-shaped into the return type.\n async releaseCode(codeId: string, cartId: string): Promise<void> {\n const { auth, scope } = resolvePromotionsServiceAuthAndScope(this.container)\n const commandBus = resolvePromotionsCommandBus(this.container)\n const ctx = buildPromotionsCommandContext(this.container, auth, scope)\n await commandBus.execute<{ codeId: string; cartId: string }, { ok: true }>(\n 'promotions.code.release',\n { input: { codeId, cartId }, ctx },\n )\n }\n\n // \u00A7A.2a: a registered command (`promotions.usage.register`) \u2014 \"financially or budget\n // consequential\" per root AGENTS.md's command-pattern rule, same as the five code-\n // service methods above.\n async registerUsage(input: RegisterUsageInput): Promise<{ ok: true; budgetExceeded: string[] }> {\n const { auth, scope } = resolvePromotionsServiceAuthAndScope(this.container)\n const commandBus = resolvePromotionsCommandBus(this.container)\n const ctx = buildPromotionsCommandContext(this.container, auth, scope)\n const { result } = await commandBus.execute<RegisterUsageInput, { ok: true; budgetExceeded: string[] }>(\n 'promotions.usage.register',\n { input, ctx },\n )\n return result\n }\n\n // \u00A7A.2a: a registered command (`promotions.usage.revert`).\n async revertUsage(input: RevertUsageInput): Promise<{ ok: true; revertedCount: number }> {\n const { auth, scope } = resolvePromotionsServiceAuthAndScope(this.container)\n const commandBus = resolvePromotionsCommandBus(this.container)\n const ctx = buildPromotionsCommandContext(this.container, auth, scope)\n const { result } = await commandBus.execute<RevertUsageInput, { ok: true; revertedCount: number }>(\n 'promotions.usage.revert',\n { input, ctx },\n )\n return result\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,3 +1,4 @@
1
+ import { TransactionContext } from "@mikro-orm/core";
1
2
  import { runWithCacheTenant } from "@open-mercato/cache";
2
3
  import { createLogger } from "@open-mercato/shared/lib/logger";
3
4
  import {
@@ -104,6 +105,7 @@ function buildNormalizedPromotion(promotionRow, ruleGroupRows, ruleRows, benefit
104
105
  return freezeOutsideProduction(normalized);
105
106
  }
106
107
  function resolvePromotionCacheStrategy(container) {
108
+ if (resolvePromotionEm(container).isInTransaction()) return null;
107
109
  try {
108
110
  return container.resolve("cache") ?? null;
109
111
  } catch {
@@ -111,13 +113,21 @@ function resolvePromotionCacheStrategy(container) {
111
113
  }
112
114
  }
113
115
  function resolvePromotionEm(container) {
114
- return container.resolve("em").fork();
116
+ const registeredEm = container.resolve("em");
117
+ const contextualEm = TransactionContext.getEntityManager(registeredEm.name);
118
+ const em = contextualEm && contextualEm.getDriver() === registeredEm.getDriver() ? contextualEm : registeredEm;
119
+ return em.fork({ keepTransactionContext: true, disableContextResolution: Boolean(contextualEm && contextualEm !== em) });
115
120
  }
116
121
  class PromotionCacheService {
117
122
  constructor(container) {
118
123
  this.container = container;
119
124
  }
120
125
  async getActivePromotions(tenantId, organizationId) {
126
+ const em = resolvePromotionEm(this.container);
127
+ if (em.isInTransaction()) {
128
+ const promotions2 = await this.buildActivePromotionTrees(tenantId, organizationId);
129
+ return this.buildTransactionalUsageSnapshot(em, promotions2, tenantId, organizationId);
130
+ }
121
131
  const cache = resolvePromotionCacheStrategy(this.container);
122
132
  const treeKey = buildPromotionTreeCacheKey(tenantId, organizationId);
123
133
  let promotions = cache ? await readPromotionCacheEntry(cache, tenantId, treeKey) : null;
@@ -131,6 +141,29 @@ class PromotionCacheService {
131
141
  promotions.map((promotion) => this.overlayUsageCounter(promotion, tenantId, organizationId))
132
142
  );
133
143
  }
144
+ async buildTransactionalUsageSnapshot(em, promotions, tenantId, organizationId) {
145
+ const budgeted = promotions.filter((promotion) => promotion.maxBudget !== null);
146
+ const currencies = [...new Set(budgeted.map((promotion) => promotion.budgetCurrency).filter((currency) => !!currency))];
147
+ const totals = /* @__PURE__ */ new Map();
148
+ if (budgeted.length > 0 && currencies.length > 0) {
149
+ const promotionIds = budgeted.map((promotion) => promotion.id);
150
+ const promotionSlots = promotionIds.map(() => "?").join(", ");
151
+ const currencySlots = currencies.map(() => "?").join(", ");
152
+ const rows = await em.execute(
153
+ `SELECT promotion_id, currency, COALESCE(SUM(total_discount_amount), 0)::text AS total
154
+ FROM promotion_usages
155
+ WHERE promotion_id IN (${promotionSlots}) AND currency IN (${currencySlots})
156
+ AND organization_id = ? AND tenant_id = ? AND reverted_at IS NULL
157
+ GROUP BY promotion_id, currency`,
158
+ [...promotionIds, ...currencies, organizationId, tenantId]
159
+ );
160
+ for (const row of rows) totals.set(`${row.promotion_id}:${row.currency}`, row.total);
161
+ }
162
+ return promotions.map((promotion) => promotion.maxBudget === null ? promotion : freezeOutsideProduction({
163
+ ...promotion,
164
+ totalDiscountGranted: totals.get(`${promotion.id}:${promotion.budgetCurrency}`) ?? "0"
165
+ }));
166
+ }
134
167
  // §Cache Build Contract item 4: "read only for budget-capped promotions" — an
135
168
  // uncapped promotion's `totalDiscountGranted` is never read back from
136
169
  // `promotion_usages`, it stays the inert '0' baked into the tree cache.
@@ -163,7 +196,7 @@ class PromotionCacheService {
163
196
  async sumPromotionUsage(promotionId, tenantId, organizationId, budgetCurrency) {
164
197
  if (!budgetCurrency) return "0";
165
198
  const em = resolvePromotionEm(this.container);
166
- const rows = await em.getConnection().execute(
199
+ const rows = await em.execute(
167
200
  `SELECT COALESCE(SUM(total_discount_amount), 0)::text AS total
168
201
  FROM promotion_usages
169
202
  WHERE promotion_id = ? AND organization_id = ? AND tenant_id = ? AND currency = ? AND reverted_at IS NULL`,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/promotions/services/promotion-cache.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { runWithCacheTenant, type CacheStrategy } from '@open-mercato/cache'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport {\n Benefit,\n Promotion,\n Rule,\n RuleGroup,\n type PromotionBenefitAppliesTo,\n type PromotionRuleGroupOperator,\n} from '../data/entities'\nimport type {\n NormalizedBenefit,\n NormalizedPromotion,\n NormalizedRule,\n NormalizedRuleGroup,\n} from '../lib/types'\n\n// SPEC-055 \u00A7Cache Build Contract + \u00A7Normalized Promotion Shape. This file is the ONLY\n// place in the module that builds `NormalizedPromotion[]` from the database \u2014 the pure\n// evaluation pipeline (`lib/evaluation-engine.ts` and friends) never touches the\n// EntityManager, only ever receives the plain objects this file produces.\n\n// ---------------------------------------------------------------------------\n// Cache keys\n// ---------------------------------------------------------------------------\n\n// Literal format pinned by SPEC-055 \u00A7Evaluation Engine Data Flow pseudocode\n// (`key: promotions:active:{tenantId}:{organizationId}`). Key and tag share the same\n// value intentionally (one tree cache entry per tenant/org, mirrors\n// `inbox_ops/lib/cache.ts`'s COUNTS/SETTINGS keys) so invalidation never needs a\n// separate tag registry.\nexport function buildPromotionTreeCacheKey(tenantId: string, organizationId: string): string {\n return `promotions:active:${tenantId}:${organizationId}`\n}\n\n// Not specified verbatim by the spec (only \"a separate, small counters key\" is\n// required, \u00A7Cache Build Contract item 4) \u2014 scoped per-promotion, not per-tenant, so\n// a `usage.registered`/`usage.reverted` event for one promotion only ever evicts that\n// promotion's own counter, never the whole tenant's budget-capped set and never the\n// (expensive-to-rebuild) main tree cache.\nexport function buildPromotionUsageCounterCacheKey(\n tenantId: string,\n organizationId: string,\n promotionId: string,\n): string {\n return `promotions:usage-counter:${tenantId}:${organizationId}:${promotionId}`\n}\n\nconst PROMOTION_TREE_CACHE_TTL_MS = 5 * 60 * 1000\nconst PROMOTION_USAGE_COUNTER_CACHE_TTL_MS = 5 * 60 * 1000\n\nasync function readPromotionCacheEntry<T>(\n cache: CacheStrategy,\n tenantId: string,\n key: string,\n): Promise<T | null> {\n try {\n const value = await runWithCacheTenant(tenantId, () => cache.get(key))\n return value === null || value === undefined ? null : (value as T)\n } catch {\n return null\n }\n}\n\nasync function writePromotionCacheEntry(\n cache: CacheStrategy,\n tenantId: string,\n key: string,\n value: unknown,\n tags: string[],\n ttlMs: number,\n): Promise<void> {\n try {\n await runWithCacheTenant(tenantId, () => cache.set(key, value, { ttl: ttlMs, tags }))\n } catch {\n // Best-effort: a cache write failure must not fail the read it is backing.\n }\n}\n\n// The subscriber handlers below (and `subscribers/*.ts`, which just wire an event id to\n// one of those handlers) call these two directly \u2014 they never build the tag string\n// themselves, so the key format stays single-sourced here.\nexport async function invalidatePromotionTreeCache(\n cache: CacheStrategy | null | undefined,\n tenantId: string,\n organizationId: string,\n): Promise<void> {\n if (!cache) return\n const tag = buildPromotionTreeCacheKey(tenantId, organizationId)\n await runWithCacheTenant(tenantId, () => cache.deleteByTags([tag]))\n}\n\nexport async function invalidatePromotionUsageCounterCache(\n cache: CacheStrategy | null | undefined,\n tenantId: string,\n organizationId: string,\n promotionId: string,\n): Promise<void> {\n if (!cache) return\n const tag = buildPromotionUsageCounterCacheKey(tenantId, organizationId, promotionId)\n await runWithCacheTenant(tenantId, () => cache.deleteByTags([tag]))\n}\n\n// ---------------------------------------------------------------------------\n// Row shapes \u2014 the already-fetched, plain-object input to the pure normalization\n// below. Field names mirror `data/entities.ts` exactly (camelCase entity properties).\n// ---------------------------------------------------------------------------\n\nexport type PromotionCacheRow = {\n id: string\n name: string\n order: number\n cumulative: boolean\n tags: string[]\n excludedTags: string[]\n excludeFlags: Record<string, boolean>\n eligibleCurrencies: string[]\n startsAt: Date | null\n endsAt: Date | null\n maxBudget: string | null\n budgetCurrency: string | null\n}\n\nexport type RuleGroupCacheRow = {\n id: string\n promotionId: string\n parentRuleGroupId: string | null\n operator: PromotionRuleGroupOperator\n sortOrder: number\n}\n\nexport type RuleCacheRow = {\n ruleGroupId: string\n promotionId: string\n ruleType: string\n config: Record<string, unknown>\n sortOrder: number\n}\n\nexport type BenefitCacheRow = {\n ruleGroupId: string\n promotionId: string\n benefitType: string\n appliesTo: PromotionBenefitAppliesTo\n config: Record<string, unknown>\n sortOrder: number\n}\n\n// ---------------------------------------------------------------------------\n// Deep freeze (\u00A7Normalized Promotion Shape: \"In non-production builds, objects are\n// deep-frozen to detect accidental mutation\")\n// ---------------------------------------------------------------------------\n\nfunction deepFreeze<T>(value: T): T {\n if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {\n Object.freeze(value)\n for (const key of Object.getOwnPropertyNames(value)) {\n deepFreeze((value as Record<string, unknown>)[key])\n }\n }\n return value\n}\n\nfunction freezeOutsideProduction<T>(value: T): T {\n return process.env.NODE_ENV === 'production' ? value : deepFreeze(value)\n}\n\n// ---------------------------------------------------------------------------\n// Pure normalization / tree assembly \u2014 no EntityManager, no cache, fully testable\n// without a database connection. Extracted per SPEC-055 Step 2.5's test requirement.\n// ---------------------------------------------------------------------------\n\nfunction buildNormalizedRuleGroup(\n groupId: string,\n groupsForPromotion: RuleGroupCacheRow[],\n rulesForPromotion: RuleCacheRow[],\n benefitsForPromotion: BenefitCacheRow[],\n): NormalizedRuleGroup {\n const group = groupsForPromotion.find((candidate) => candidate.id === groupId)\n if (!group) {\n // \u00A7Cache Build Contract item 3: \"Any missing eager-loaded relation is treated as a\n // critical bug.\" A child id referenced by `parentRuleGroupId` that was not fetched\n // in the same batch is exactly that \u2014 never silently produce an incomplete tree.\n throw new Error(`[internal] promotion cache build is missing rule group ${groupId}`)\n }\n\n const rules: NormalizedRule[] = rulesForPromotion\n .filter((rule) => rule.ruleGroupId === groupId)\n .slice()\n .sort((a, b) => a.sortOrder - b.sortOrder)\n .map((rule) => ({ type: rule.ruleType, config: rule.config }))\n\n const benefits: NormalizedBenefit[] = benefitsForPromotion\n .filter((benefit) => benefit.ruleGroupId === groupId)\n .slice()\n .sort((a, b) => a.sortOrder - b.sortOrder)\n .map((benefit) => ({ type: benefit.benefitType, appliesTo: benefit.appliesTo, config: benefit.config }))\n\n const children: NormalizedRuleGroup[] = groupsForPromotion\n .filter((candidate) => candidate.parentRuleGroupId === groupId)\n .slice()\n .sort((a, b) => a.sortOrder - b.sortOrder)\n .map((child) => buildNormalizedRuleGroup(child.id, groupsForPromotion, rulesForPromotion, benefitsForPromotion))\n\n return { operator: group.operator, rules, benefits, children }\n}\n\n/**\n * Pure tree assembly for a single promotion. Accepts the full fetched batch for every\n * rule-group/rule/benefit row (not pre-filtered per promotion) and narrows internally by\n * `promotionId` \u2014 the real service fetches these arrays once for a whole batch of active\n * promotions (\u00A7Cache Build Contract item 1: \"a single cache build performs all required\n * database reads\", not N+1 per promotion) and calls this function once per promotion.\n *\n * `totalDiscountGranted` is passed in already resolved \u2014 this function never reads\n * `promotion_usages` itself (\u00A7Cache Build Contract item 4: the counter lives in a\n * separate, small cache key the caller resolves independently).\n */\nexport function buildNormalizedPromotion(\n promotionRow: PromotionCacheRow,\n ruleGroupRows: RuleGroupCacheRow[],\n ruleRows: RuleCacheRow[],\n benefitRows: BenefitCacheRow[],\n totalDiscountGranted: string,\n): NormalizedPromotion {\n const groupsForPromotion = ruleGroupRows.filter((group) => group.promotionId === promotionRow.id)\n const rulesForPromotion = ruleRows.filter((rule) => rule.promotionId === promotionRow.id)\n const benefitsForPromotion = benefitRows.filter((benefit) => benefit.promotionId === promotionRow.id)\n\n const root = groupsForPromotion.find((group) => group.parentRuleGroupId === null)\n if (!root) {\n throw new Error(\n `[internal] promotion cache build is missing the root rule group for promotion ${promotionRow.id}`,\n )\n }\n\n // \u00A7Cache Build Contract item 3: a rule/benefit whose owning `ruleGroupId` was not\n // among the batch-fetched groups is exactly the \"missing eager-loaded relation\"\n // scenario \u2014 never silently drop it from the tree.\n const groupIdsForPromotion = new Set(groupsForPromotion.map((group) => group.id))\n for (const rule of rulesForPromotion) {\n if (!groupIdsForPromotion.has(rule.ruleGroupId)) {\n throw new Error(\n `[internal] promotion cache build is missing rule group ${rule.ruleGroupId} referenced by a rule for promotion ${promotionRow.id}`,\n )\n }\n }\n for (const benefit of benefitsForPromotion) {\n if (!groupIdsForPromotion.has(benefit.ruleGroupId)) {\n throw new Error(\n `[internal] promotion cache build is missing rule group ${benefit.ruleGroupId} referenced by a benefit for promotion ${promotionRow.id}`,\n )\n }\n }\n\n const normalized: NormalizedPromotion = {\n id: promotionRow.id,\n name: promotionRow.name,\n order: promotionRow.order,\n cumulative: promotionRow.cumulative,\n tags: [...promotionRow.tags],\n excludedTags: [...promotionRow.excludedTags],\n excludeFlags: { ...promotionRow.excludeFlags },\n eligibleCurrencies: [...promotionRow.eligibleCurrencies],\n startsAt: promotionRow.startsAt ? promotionRow.startsAt.toISOString() : null,\n endsAt: promotionRow.endsAt ? promotionRow.endsAt.toISOString() : null,\n maxBudget: promotionRow.maxBudget,\n budgetCurrency: promotionRow.budgetCurrency,\n totalDiscountGranted,\n rootGroup: buildNormalizedRuleGroup(root.id, groupsForPromotion, rulesForPromotion, benefitsForPromotion),\n }\n\n return freezeOutsideProduction(normalized)\n}\n\n// ---------------------------------------------------------------------------\n// Cache-backed service \u2014 the only place in this module that ever queries the\n// database for the evaluation engine's input (\u00A7Cache Build Contract item 2).\n// ---------------------------------------------------------------------------\n\nexport type PromotionCacheContainer = {\n resolve: <T = unknown>(name: string) => T\n}\n\nfunction resolvePromotionCacheStrategy(container: PromotionCacheContainer): CacheStrategy | null {\n try {\n return container.resolve<CacheStrategy>('cache') ?? null\n } catch {\n return null\n }\n}\n\nfunction resolvePromotionEm(container: PromotionCacheContainer): EntityManager {\n // Exact precedent: `commands/promotions.ts` resolves/forks the same way \u2014\n // `(ctx.container.resolve('em') as EntityManager).fork()` \u2014 before any read/write.\n return (container.resolve<EntityManager>('em')).fork()\n}\n\nexport class PromotionCacheService {\n constructor(private readonly container: PromotionCacheContainer) {}\n\n async getActivePromotions(tenantId: string, organizationId: string): Promise<NormalizedPromotion[]> {\n const cache = resolvePromotionCacheStrategy(this.container)\n const treeKey = buildPromotionTreeCacheKey(tenantId, organizationId)\n\n let promotions = cache ? await readPromotionCacheEntry<NormalizedPromotion[]>(cache, tenantId, treeKey) : null\n if (!promotions) {\n promotions = await this.buildActivePromotionTrees(tenantId, organizationId)\n if (cache) {\n await writePromotionCacheEntry(cache, tenantId, treeKey, promotions, [treeKey], PROMOTION_TREE_CACHE_TTL_MS)\n }\n }\n\n return Promise.all(\n promotions.map((promotion) => this.overlayUsageCounter(promotion, tenantId, organizationId)),\n )\n }\n\n // \u00A7Cache Build Contract item 4: \"read only for budget-capped promotions\" \u2014 an\n // uncapped promotion's `totalDiscountGranted` is never read back from\n // `promotion_usages`, it stays the inert '0' baked into the tree cache.\n private async overlayUsageCounter(\n promotion: NormalizedPromotion,\n tenantId: string,\n organizationId: string,\n ): Promise<NormalizedPromotion> {\n if (promotion.maxBudget === null) return promotion\n const totalDiscountGranted = await this.getUsageCounter(\n promotion.id,\n tenantId,\n organizationId,\n promotion.budgetCurrency,\n )\n return freezeOutsideProduction({ ...promotion, totalDiscountGranted })\n }\n\n private async getUsageCounter(\n promotionId: string,\n tenantId: string,\n organizationId: string,\n budgetCurrency: string | null,\n ): Promise<string> {\n const cache = resolvePromotionCacheStrategy(this.container)\n const key = buildPromotionUsageCounterCacheKey(tenantId, organizationId, promotionId)\n\n if (cache) {\n const cached = await readPromotionCacheEntry<string>(cache, tenantId, key)\n if (cached !== null) return cached\n }\n\n const total = await this.sumPromotionUsage(promotionId, tenantId, organizationId, budgetCurrency)\n\n if (cache) {\n await writePromotionCacheEntry(cache, tenantId, key, total, [key], PROMOTION_USAGE_COUNTER_CACHE_TTL_MS)\n }\n\n return total\n }\n\n // \u00A7Cache Build Contract item 1 / \u00A7Promotion Usage Lifecycle: `SUM(total_discount_amount)\n // WHERE reverted_at IS NULL AND currency = budget_currency` \u2014 the same filter\n // `registerUsage`'s own re-read-sum uses.\n private async sumPromotionUsage(\n promotionId: string,\n tenantId: string,\n organizationId: string,\n budgetCurrency: string | null,\n ): Promise<string> {\n if (!budgetCurrency) return '0'\n const em = resolvePromotionEm(this.container)\n const rows = (await em.getConnection().execute(\n `SELECT COALESCE(SUM(total_discount_amount), 0)::text AS total\n FROM promotion_usages\n WHERE promotion_id = ? AND organization_id = ? AND tenant_id = ? AND currency = ? AND reverted_at IS NULL`,\n [promotionId, organizationId, tenantId, budgetCurrency],\n )) as Array<{ total: string }>\n return rows[0]?.total ?? '0'\n }\n\n // \u00A7Cache Build Contract item 1: one query pass for the whole batch of active\n // promotions \u2014 every `RuleGroup`/`Rule`/`Benefit` row for them is fetched in three\n // fixed queries (run in parallel), never per-promotion.\n private async buildActivePromotionTrees(\n tenantId: string,\n organizationId: string,\n ): Promise<NormalizedPromotion[]> {\n const em = resolvePromotionEm(this.container)\n\n // \u00A7Cache Build Contract (rev R-H3b/P8): every `active = true` promotion, regardless\n // of `starts_at`/`ends_at` \u2014 date-window filtering happens only inside the evaluator\n // (`lib/evaluation-engine.ts`'s `isPromotionEligible`), never here.\n const promotionEntities = await em.find(\n Promotion,\n { tenantId, organizationId, active: true, deletedAt: null },\n // `lib/evaluation-engine.ts` documents that the cache is responsible for ordering\n // by `order ASC, id ASC` before evaluation ever sees the array.\n { orderBy: { order: 'asc', id: 'asc' } },\n )\n if (promotionEntities.length === 0) return []\n\n const promotionIds = promotionEntities.map((promotion) => promotion.id)\n\n const [ruleGroupEntities, ruleEntities, benefitEntities] = await Promise.all([\n em.find(RuleGroup, { tenantId, organizationId, promotionId: { $in: promotionIds } }),\n em.find(Rule, { tenantId, organizationId, promotionId: { $in: promotionIds } }),\n em.find(Benefit, { tenantId, organizationId, promotionId: { $in: promotionIds } }),\n ])\n\n const ruleGroupRows: RuleGroupCacheRow[] = ruleGroupEntities.map((group) => ({\n id: group.id,\n promotionId: group.promotionId,\n parentRuleGroupId: group.parentRuleGroupId ?? null,\n operator: group.operator,\n sortOrder: group.sortOrder,\n }))\n const ruleRows: RuleCacheRow[] = ruleEntities.map((rule) => ({\n ruleGroupId: rule.ruleGroupId,\n promotionId: rule.promotionId,\n ruleType: rule.ruleType,\n config: rule.config,\n sortOrder: rule.sortOrder,\n }))\n const benefitRows: BenefitCacheRow[] = benefitEntities.map((benefit) => ({\n ruleGroupId: benefit.ruleGroupId,\n promotionId: benefit.promotionId,\n benefitType: benefit.benefitType,\n appliesTo: benefit.appliesTo,\n config: benefit.config,\n sortOrder: benefit.sortOrder,\n }))\n\n return promotionEntities.map((promotion) => {\n const row: PromotionCacheRow = {\n id: promotion.id,\n name: promotion.name,\n order: promotion.order,\n cumulative: promotion.cumulative,\n tags: promotion.tags,\n excludedTags: promotion.excludedTags,\n excludeFlags: promotion.excludeFlags,\n eligibleCurrencies: promotion.eligibleCurrencies,\n startsAt: promotion.startsAt ?? null,\n endsAt: promotion.endsAt ?? null,\n maxBudget: promotion.maxBudget ?? null,\n budgetCurrency: promotion.budgetCurrency ?? null,\n }\n // Placeholder \u2014 `getActivePromotions` overlays the real snapshot for\n // budget-capped promotions from the separate counters cache key right after this\n // method returns (\u00A7Cache Build Contract item 4). Never read directly from the\n // tree cache for a budget decision.\n return buildNormalizedPromotion(row, ruleGroupRows, ruleRows, benefitRows, '0')\n })\n }\n}\n\n// ---------------------------------------------------------------------------\n// Subscriber glue (`subscribers/*.ts` \u2014 ephemeral cache-invalidation handlers).\n// Kept here, not in `subscribers/` itself: every file under a module's `subscribers/`\n// folder is auto-discovered as a subscriber definition by `yarn generate` (it is scanned\n// unconditionally, with no `metadata` export treated as a critical bug further down the\n// pipeline, not as \"skip this file\") \u2014 mirrors `ecommerce/lib/cacheInvalidation.ts`\n// shared by that module's own `subscribers/*-cache-invalidation.ts` files.\n// ---------------------------------------------------------------------------\n\nexport type PromotionsSubscriberContext = {\n resolve: <T = unknown>(name: string) => T\n tenantId?: string | null\n organizationId?: string | null\n}\n\nconst subscriberLogger = createLogger('promotions').child({ component: 'cache' })\n\nfunction readSubscriberEventScope(\n payload: unknown,\n ctx: PromotionsSubscriberContext,\n): { tenantId: string | null; organizationId: string | null } {\n const data = (payload ?? {}) as Record<string, unknown>\n const tenantId =\n typeof data.tenantId === 'string' && data.tenantId.length > 0 ? data.tenantId : ctx.tenantId ?? null\n const organizationId =\n typeof data.organizationId === 'string' && data.organizationId.length > 0\n ? data.organizationId\n : ctx.organizationId ?? null\n return { tenantId, organizationId }\n}\n\nfunction readPromotionIdFromUsagePayload(payload: unknown): string | null {\n const data = (payload ?? {}) as Record<string, unknown>\n return typeof data.promotionId === 'string' && data.promotionId.length > 0 ? data.promotionId : null\n}\n\nfunction resolveSubscriberCache(ctx: PromotionsSubscriberContext): CacheStrategy | null {\n try {\n return ctx.resolve<CacheStrategy>('cache') ?? null\n } catch {\n return null\n }\n}\n\n// \u00A7Cache Build Contract item 5: the main tree cache is invalidated ONLY by promotion/\n// code mutation events (`promotions.promotion.created|updated|deleted`,\n// `promotions.code.created|updated|deleted`) \u2014 never by a usage event. Wired to those\n// six exact event ids (not a `promotions.promotion.*`/`promotions.code.*` wildcard) so\n// the lifecycle events declared alongside them in `events.ts`\n// (`promotions.promotion.budget_exhausted`/`.expiring_soon`, `promotions.code.used`/\n// `.exhausted`) never trigger a rebuild they were not asked to.\nexport async function handlePromotionTreeCacheInvalidation(\n payload: unknown,\n ctx: PromotionsSubscriberContext,\n): Promise<void> {\n const { tenantId, organizationId } = readSubscriberEventScope(payload, ctx)\n if (!tenantId || !organizationId) return\n const cache = resolveSubscriberCache(ctx)\n if (!cache) return\n try {\n await invalidatePromotionTreeCache(cache, tenantId, organizationId)\n } catch (err) {\n subscriberLogger.warn('[internal] promotion tree cache invalidation failed', { tenantId, organizationId, err })\n }\n}\n\n// \u00A7Cache Build Contract item 4/5: `promotions.usage.registered`/`.reverted` evict only\n// the one promotion's small counters-key entry, never the main tree cache.\nexport async function handlePromotionUsageCounterCacheInvalidation(\n payload: unknown,\n ctx: PromotionsSubscriberContext,\n): Promise<void> {\n const { tenantId, organizationId } = readSubscriberEventScope(payload, ctx)\n const promotionId = readPromotionIdFromUsagePayload(payload)\n if (!tenantId || !organizationId || !promotionId) return\n const cache = resolveSubscriberCache(ctx)\n if (!cache) return\n try {\n await invalidatePromotionUsageCounterCache(cache, tenantId, organizationId, promotionId)\n } catch (err) {\n subscriberLogger.warn('[internal] promotion usage counter cache invalidation failed', {\n tenantId,\n organizationId,\n promotionId,\n err,\n })\n }\n}\n"],
5
- "mappings": "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",
6
- "names": []
4
+ "sourcesContent": ["import { TransactionContext } from '@mikro-orm/core'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { runWithCacheTenant, type CacheStrategy } from '@open-mercato/cache'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport {\n Benefit,\n Promotion,\n Rule,\n RuleGroup,\n type PromotionBenefitAppliesTo,\n type PromotionRuleGroupOperator,\n} from '../data/entities'\nimport type {\n NormalizedBenefit,\n NormalizedPromotion,\n NormalizedRule,\n NormalizedRuleGroup,\n} from '../lib/types'\n\n// SPEC-055 \u00A7Cache Build Contract + \u00A7Normalized Promotion Shape. This file is the ONLY\n// place in the module that builds `NormalizedPromotion[]` from the database \u2014 the pure\n// evaluation pipeline (`lib/evaluation-engine.ts` and friends) never touches the\n// EntityManager, only ever receives the plain objects this file produces.\n\n// ---------------------------------------------------------------------------\n// Cache keys\n// ---------------------------------------------------------------------------\n\n// Literal format pinned by SPEC-055 \u00A7Evaluation Engine Data Flow pseudocode\n// (`key: promotions:active:{tenantId}:{organizationId}`). Key and tag share the same\n// value intentionally (one tree cache entry per tenant/org, mirrors\n// `inbox_ops/lib/cache.ts`'s COUNTS/SETTINGS keys) so invalidation never needs a\n// separate tag registry.\nexport function buildPromotionTreeCacheKey(tenantId: string, organizationId: string): string {\n return `promotions:active:${tenantId}:${organizationId}`\n}\n\n// Not specified verbatim by the spec (only \"a separate, small counters key\" is\n// required, \u00A7Cache Build Contract item 4) \u2014 scoped per-promotion, not per-tenant, so\n// a `usage.registered`/`usage.reverted` event for one promotion only ever evicts that\n// promotion's own counter, never the whole tenant's budget-capped set and never the\n// (expensive-to-rebuild) main tree cache.\nexport function buildPromotionUsageCounterCacheKey(\n tenantId: string,\n organizationId: string,\n promotionId: string,\n): string {\n return `promotions:usage-counter:${tenantId}:${organizationId}:${promotionId}`\n}\n\nconst PROMOTION_TREE_CACHE_TTL_MS = 5 * 60 * 1000\nconst PROMOTION_USAGE_COUNTER_CACHE_TTL_MS = 5 * 60 * 1000\n\nasync function readPromotionCacheEntry<T>(\n cache: CacheStrategy,\n tenantId: string,\n key: string,\n): Promise<T | null> {\n try {\n const value = await runWithCacheTenant(tenantId, () => cache.get(key))\n return value === null || value === undefined ? null : (value as T)\n } catch {\n return null\n }\n}\n\nasync function writePromotionCacheEntry(\n cache: CacheStrategy,\n tenantId: string,\n key: string,\n value: unknown,\n tags: string[],\n ttlMs: number,\n): Promise<void> {\n try {\n await runWithCacheTenant(tenantId, () => cache.set(key, value, { ttl: ttlMs, tags }))\n } catch {\n // Best-effort: a cache write failure must not fail the read it is backing.\n }\n}\n\n// The subscriber handlers below (and `subscribers/*.ts`, which just wire an event id to\n// one of those handlers) call these two directly \u2014 they never build the tag string\n// themselves, so the key format stays single-sourced here.\nexport async function invalidatePromotionTreeCache(\n cache: CacheStrategy | null | undefined,\n tenantId: string,\n organizationId: string,\n): Promise<void> {\n if (!cache) return\n const tag = buildPromotionTreeCacheKey(tenantId, organizationId)\n await runWithCacheTenant(tenantId, () => cache.deleteByTags([tag]))\n}\n\nexport async function invalidatePromotionUsageCounterCache(\n cache: CacheStrategy | null | undefined,\n tenantId: string,\n organizationId: string,\n promotionId: string,\n): Promise<void> {\n if (!cache) return\n const tag = buildPromotionUsageCounterCacheKey(tenantId, organizationId, promotionId)\n await runWithCacheTenant(tenantId, () => cache.deleteByTags([tag]))\n}\n\n// ---------------------------------------------------------------------------\n// Row shapes \u2014 the already-fetched, plain-object input to the pure normalization\n// below. Field names mirror `data/entities.ts` exactly (camelCase entity properties).\n// ---------------------------------------------------------------------------\n\nexport type PromotionCacheRow = {\n id: string\n name: string\n order: number\n cumulative: boolean\n tags: string[]\n excludedTags: string[]\n excludeFlags: Record<string, boolean>\n eligibleCurrencies: string[]\n startsAt: Date | null\n endsAt: Date | null\n maxBudget: string | null\n budgetCurrency: string | null\n}\n\nexport type RuleGroupCacheRow = {\n id: string\n promotionId: string\n parentRuleGroupId: string | null\n operator: PromotionRuleGroupOperator\n sortOrder: number\n}\n\nexport type RuleCacheRow = {\n ruleGroupId: string\n promotionId: string\n ruleType: string\n config: Record<string, unknown>\n sortOrder: number\n}\n\nexport type BenefitCacheRow = {\n ruleGroupId: string\n promotionId: string\n benefitType: string\n appliesTo: PromotionBenefitAppliesTo\n config: Record<string, unknown>\n sortOrder: number\n}\n\n// ---------------------------------------------------------------------------\n// Deep freeze (\u00A7Normalized Promotion Shape: \"In non-production builds, objects are\n// deep-frozen to detect accidental mutation\")\n// ---------------------------------------------------------------------------\n\nfunction deepFreeze<T>(value: T): T {\n if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {\n Object.freeze(value)\n for (const key of Object.getOwnPropertyNames(value)) {\n deepFreeze((value as Record<string, unknown>)[key])\n }\n }\n return value\n}\n\nfunction freezeOutsideProduction<T>(value: T): T {\n return process.env.NODE_ENV === 'production' ? value : deepFreeze(value)\n}\n\n// ---------------------------------------------------------------------------\n// Pure normalization / tree assembly \u2014 no EntityManager, no cache, fully testable\n// without a database connection. Extracted per SPEC-055 Step 2.5's test requirement.\n// ---------------------------------------------------------------------------\n\nfunction buildNormalizedRuleGroup(\n groupId: string,\n groupsForPromotion: RuleGroupCacheRow[],\n rulesForPromotion: RuleCacheRow[],\n benefitsForPromotion: BenefitCacheRow[],\n): NormalizedRuleGroup {\n const group = groupsForPromotion.find((candidate) => candidate.id === groupId)\n if (!group) {\n // \u00A7Cache Build Contract item 3: \"Any missing eager-loaded relation is treated as a\n // critical bug.\" A child id referenced by `parentRuleGroupId` that was not fetched\n // in the same batch is exactly that \u2014 never silently produce an incomplete tree.\n throw new Error(`[internal] promotion cache build is missing rule group ${groupId}`)\n }\n\n const rules: NormalizedRule[] = rulesForPromotion\n .filter((rule) => rule.ruleGroupId === groupId)\n .slice()\n .sort((a, b) => a.sortOrder - b.sortOrder)\n .map((rule) => ({ type: rule.ruleType, config: rule.config }))\n\n const benefits: NormalizedBenefit[] = benefitsForPromotion\n .filter((benefit) => benefit.ruleGroupId === groupId)\n .slice()\n .sort((a, b) => a.sortOrder - b.sortOrder)\n .map((benefit) => ({ type: benefit.benefitType, appliesTo: benefit.appliesTo, config: benefit.config }))\n\n const children: NormalizedRuleGroup[] = groupsForPromotion\n .filter((candidate) => candidate.parentRuleGroupId === groupId)\n .slice()\n .sort((a, b) => a.sortOrder - b.sortOrder)\n .map((child) => buildNormalizedRuleGroup(child.id, groupsForPromotion, rulesForPromotion, benefitsForPromotion))\n\n return { operator: group.operator, rules, benefits, children }\n}\n\n/**\n * Pure tree assembly for a single promotion. Accepts the full fetched batch for every\n * rule-group/rule/benefit row (not pre-filtered per promotion) and narrows internally by\n * `promotionId` \u2014 the real service fetches these arrays once for a whole batch of active\n * promotions (\u00A7Cache Build Contract item 1: \"a single cache build performs all required\n * database reads\", not N+1 per promotion) and calls this function once per promotion.\n *\n * `totalDiscountGranted` is passed in already resolved \u2014 this function never reads\n * `promotion_usages` itself (\u00A7Cache Build Contract item 4: the counter lives in a\n * separate, small cache key the caller resolves independently).\n */\nexport function buildNormalizedPromotion(\n promotionRow: PromotionCacheRow,\n ruleGroupRows: RuleGroupCacheRow[],\n ruleRows: RuleCacheRow[],\n benefitRows: BenefitCacheRow[],\n totalDiscountGranted: string,\n): NormalizedPromotion {\n const groupsForPromotion = ruleGroupRows.filter((group) => group.promotionId === promotionRow.id)\n const rulesForPromotion = ruleRows.filter((rule) => rule.promotionId === promotionRow.id)\n const benefitsForPromotion = benefitRows.filter((benefit) => benefit.promotionId === promotionRow.id)\n\n const root = groupsForPromotion.find((group) => group.parentRuleGroupId === null)\n if (!root) {\n throw new Error(\n `[internal] promotion cache build is missing the root rule group for promotion ${promotionRow.id}`,\n )\n }\n\n // \u00A7Cache Build Contract item 3: a rule/benefit whose owning `ruleGroupId` was not\n // among the batch-fetched groups is exactly the \"missing eager-loaded relation\"\n // scenario \u2014 never silently drop it from the tree.\n const groupIdsForPromotion = new Set(groupsForPromotion.map((group) => group.id))\n for (const rule of rulesForPromotion) {\n if (!groupIdsForPromotion.has(rule.ruleGroupId)) {\n throw new Error(\n `[internal] promotion cache build is missing rule group ${rule.ruleGroupId} referenced by a rule for promotion ${promotionRow.id}`,\n )\n }\n }\n for (const benefit of benefitsForPromotion) {\n if (!groupIdsForPromotion.has(benefit.ruleGroupId)) {\n throw new Error(\n `[internal] promotion cache build is missing rule group ${benefit.ruleGroupId} referenced by a benefit for promotion ${promotionRow.id}`,\n )\n }\n }\n\n const normalized: NormalizedPromotion = {\n id: promotionRow.id,\n name: promotionRow.name,\n order: promotionRow.order,\n cumulative: promotionRow.cumulative,\n tags: [...promotionRow.tags],\n excludedTags: [...promotionRow.excludedTags],\n excludeFlags: { ...promotionRow.excludeFlags },\n eligibleCurrencies: [...promotionRow.eligibleCurrencies],\n startsAt: promotionRow.startsAt ? promotionRow.startsAt.toISOString() : null,\n endsAt: promotionRow.endsAt ? promotionRow.endsAt.toISOString() : null,\n maxBudget: promotionRow.maxBudget,\n budgetCurrency: promotionRow.budgetCurrency,\n totalDiscountGranted,\n rootGroup: buildNormalizedRuleGroup(root.id, groupsForPromotion, rulesForPromotion, benefitsForPromotion),\n }\n\n return freezeOutsideProduction(normalized)\n}\n\n// ---------------------------------------------------------------------------\n// Cache-backed service \u2014 the only place in this module that ever queries the\n// database for the evaluation engine's input (\u00A7Cache Build Contract item 2).\n// ---------------------------------------------------------------------------\n\nexport type PromotionCacheContainer = {\n resolve: <T = unknown>(name: string) => T\n}\n\nfunction resolvePromotionCacheStrategy(container: PromotionCacheContainer): CacheStrategy | null {\n if (resolvePromotionEm(container).isInTransaction()) return null\n try {\n return container.resolve<CacheStrategy>('cache') ?? null\n } catch {\n return null\n }\n}\n\nfunction resolvePromotionEm(container: PromotionCacheContainer): EntityManager {\n const registeredEm = container.resolve<EntityManager>('em')\n const contextualEm = TransactionContext.getEntityManager(registeredEm.name) as EntityManager | undefined\n const em = contextualEm && contextualEm.getDriver() === registeredEm.getDriver() ? contextualEm : registeredEm\n return em.fork({ keepTransactionContext: true, disableContextResolution: Boolean(contextualEm && contextualEm !== em) })\n}\n\nexport class PromotionCacheService {\n constructor(private readonly container: PromotionCacheContainer) {}\n\n async getActivePromotions(tenantId: string, organizationId: string): Promise<NormalizedPromotion[]> {\n const em = resolvePromotionEm(this.container)\n if (em.isInTransaction()) {\n const promotions = await this.buildActivePromotionTrees(tenantId, organizationId)\n return this.buildTransactionalUsageSnapshot(em, promotions, tenantId, organizationId)\n }\n const cache = resolvePromotionCacheStrategy(this.container)\n const treeKey = buildPromotionTreeCacheKey(tenantId, organizationId)\n\n let promotions = cache ? await readPromotionCacheEntry<NormalizedPromotion[]>(cache, tenantId, treeKey) : null\n if (!promotions) {\n promotions = await this.buildActivePromotionTrees(tenantId, organizationId)\n if (cache) {\n await writePromotionCacheEntry(cache, tenantId, treeKey, promotions, [treeKey], PROMOTION_TREE_CACHE_TTL_MS)\n }\n }\n\n return Promise.all(\n promotions.map((promotion) => this.overlayUsageCounter(promotion, tenantId, organizationId)),\n )\n }\n\n private async buildTransactionalUsageSnapshot(\n em: EntityManager,\n promotions: NormalizedPromotion[],\n tenantId: string,\n organizationId: string,\n ): Promise<NormalizedPromotion[]> {\n const budgeted = promotions.filter((promotion) => promotion.maxBudget !== null)\n const currencies = [...new Set(budgeted.map((promotion) => promotion.budgetCurrency).filter((currency): currency is string => !!currency))]\n const totals = new Map<string, string>()\n if (budgeted.length > 0 && currencies.length > 0) {\n const promotionIds = budgeted.map((promotion) => promotion.id)\n const promotionSlots = promotionIds.map(() => '?').join(', ')\n const currencySlots = currencies.map(() => '?').join(', ')\n const rows = await em.execute<Array<{ promotion_id: string; currency: string; total: string }>>(\n `SELECT promotion_id, currency, COALESCE(SUM(total_discount_amount), 0)::text AS total\n FROM promotion_usages\n WHERE promotion_id IN (${promotionSlots}) AND currency IN (${currencySlots})\n AND organization_id = ? AND tenant_id = ? AND reverted_at IS NULL\n GROUP BY promotion_id, currency`,\n [...promotionIds, ...currencies, organizationId, tenantId],\n )\n for (const row of rows) totals.set(`${row.promotion_id}:${row.currency}`, row.total)\n }\n return promotions.map((promotion) => promotion.maxBudget === null ? promotion : freezeOutsideProduction({\n ...promotion,\n totalDiscountGranted: totals.get(`${promotion.id}:${promotion.budgetCurrency}`) ?? '0',\n }))\n }\n\n // \u00A7Cache Build Contract item 4: \"read only for budget-capped promotions\" \u2014 an\n // uncapped promotion's `totalDiscountGranted` is never read back from\n // `promotion_usages`, it stays the inert '0' baked into the tree cache.\n private async overlayUsageCounter(\n promotion: NormalizedPromotion,\n tenantId: string,\n organizationId: string,\n ): Promise<NormalizedPromotion> {\n if (promotion.maxBudget === null) return promotion\n const totalDiscountGranted = await this.getUsageCounter(\n promotion.id,\n tenantId,\n organizationId,\n promotion.budgetCurrency,\n )\n return freezeOutsideProduction({ ...promotion, totalDiscountGranted })\n }\n\n private async getUsageCounter(\n promotionId: string,\n tenantId: string,\n organizationId: string,\n budgetCurrency: string | null,\n ): Promise<string> {\n const cache = resolvePromotionCacheStrategy(this.container)\n const key = buildPromotionUsageCounterCacheKey(tenantId, organizationId, promotionId)\n\n if (cache) {\n const cached = await readPromotionCacheEntry<string>(cache, tenantId, key)\n if (cached !== null) return cached\n }\n\n const total = await this.sumPromotionUsage(promotionId, tenantId, organizationId, budgetCurrency)\n\n if (cache) {\n await writePromotionCacheEntry(cache, tenantId, key, total, [key], PROMOTION_USAGE_COUNTER_CACHE_TTL_MS)\n }\n\n return total\n }\n\n // \u00A7Cache Build Contract item 1 / \u00A7Promotion Usage Lifecycle: `SUM(total_discount_amount)\n // WHERE reverted_at IS NULL AND currency = budget_currency` \u2014 the same filter\n // `registerUsage`'s own re-read-sum uses.\n private async sumPromotionUsage(\n promotionId: string,\n tenantId: string,\n organizationId: string,\n budgetCurrency: string | null,\n ): Promise<string> {\n if (!budgetCurrency) return '0'\n const em = resolvePromotionEm(this.container)\n const rows = (await em.execute(\n `SELECT COALESCE(SUM(total_discount_amount), 0)::text AS total\n FROM promotion_usages\n WHERE promotion_id = ? AND organization_id = ? AND tenant_id = ? AND currency = ? AND reverted_at IS NULL`,\n [promotionId, organizationId, tenantId, budgetCurrency],\n )) as Array<{ total: string }>\n return rows[0]?.total ?? '0'\n }\n\n // \u00A7Cache Build Contract item 1: one query pass for the whole batch of active\n // promotions \u2014 every `RuleGroup`/`Rule`/`Benefit` row for them is fetched in three\n // fixed queries (run in parallel), never per-promotion.\n private async buildActivePromotionTrees(\n tenantId: string,\n organizationId: string,\n ): Promise<NormalizedPromotion[]> {\n const em = resolvePromotionEm(this.container)\n\n // \u00A7Cache Build Contract (rev R-H3b/P8): every `active = true` promotion, regardless\n // of `starts_at`/`ends_at` \u2014 date-window filtering happens only inside the evaluator\n // (`lib/evaluation-engine.ts`'s `isPromotionEligible`), never here.\n const promotionEntities = await em.find(\n Promotion,\n { tenantId, organizationId, active: true, deletedAt: null },\n // `lib/evaluation-engine.ts` documents that the cache is responsible for ordering\n // by `order ASC, id ASC` before evaluation ever sees the array.\n { orderBy: { order: 'asc', id: 'asc' } },\n )\n if (promotionEntities.length === 0) return []\n\n const promotionIds = promotionEntities.map((promotion) => promotion.id)\n\n const [ruleGroupEntities, ruleEntities, benefitEntities] = await Promise.all([\n em.find(RuleGroup, { tenantId, organizationId, promotionId: { $in: promotionIds } }),\n em.find(Rule, { tenantId, organizationId, promotionId: { $in: promotionIds } }),\n em.find(Benefit, { tenantId, organizationId, promotionId: { $in: promotionIds } }),\n ])\n\n const ruleGroupRows: RuleGroupCacheRow[] = ruleGroupEntities.map((group) => ({\n id: group.id,\n promotionId: group.promotionId,\n parentRuleGroupId: group.parentRuleGroupId ?? null,\n operator: group.operator,\n sortOrder: group.sortOrder,\n }))\n const ruleRows: RuleCacheRow[] = ruleEntities.map((rule) => ({\n ruleGroupId: rule.ruleGroupId,\n promotionId: rule.promotionId,\n ruleType: rule.ruleType,\n config: rule.config,\n sortOrder: rule.sortOrder,\n }))\n const benefitRows: BenefitCacheRow[] = benefitEntities.map((benefit) => ({\n ruleGroupId: benefit.ruleGroupId,\n promotionId: benefit.promotionId,\n benefitType: benefit.benefitType,\n appliesTo: benefit.appliesTo,\n config: benefit.config,\n sortOrder: benefit.sortOrder,\n }))\n\n return promotionEntities.map((promotion) => {\n const row: PromotionCacheRow = {\n id: promotion.id,\n name: promotion.name,\n order: promotion.order,\n cumulative: promotion.cumulative,\n tags: promotion.tags,\n excludedTags: promotion.excludedTags,\n excludeFlags: promotion.excludeFlags,\n eligibleCurrencies: promotion.eligibleCurrencies,\n startsAt: promotion.startsAt ?? null,\n endsAt: promotion.endsAt ?? null,\n maxBudget: promotion.maxBudget ?? null,\n budgetCurrency: promotion.budgetCurrency ?? null,\n }\n // Placeholder \u2014 `getActivePromotions` overlays the real snapshot for\n // budget-capped promotions from the separate counters cache key right after this\n // method returns (\u00A7Cache Build Contract item 4). Never read directly from the\n // tree cache for a budget decision.\n return buildNormalizedPromotion(row, ruleGroupRows, ruleRows, benefitRows, '0')\n })\n }\n}\n\n// ---------------------------------------------------------------------------\n// Subscriber glue (`subscribers/*.ts` \u2014 ephemeral cache-invalidation handlers).\n// Kept here, not in `subscribers/` itself: every file under a module's `subscribers/`\n// folder is auto-discovered as a subscriber definition by `yarn generate` (it is scanned\n// unconditionally, with no `metadata` export treated as a critical bug further down the\n// pipeline, not as \"skip this file\") \u2014 mirrors `ecommerce/lib/cacheInvalidation.ts`\n// shared by that module's own `subscribers/*-cache-invalidation.ts` files.\n// ---------------------------------------------------------------------------\n\nexport type PromotionsSubscriberContext = {\n resolve: <T = unknown>(name: string) => T\n tenantId?: string | null\n organizationId?: string | null\n}\n\nconst subscriberLogger = createLogger('promotions').child({ component: 'cache' })\n\nfunction readSubscriberEventScope(\n payload: unknown,\n ctx: PromotionsSubscriberContext,\n): { tenantId: string | null; organizationId: string | null } {\n const data = (payload ?? {}) as Record<string, unknown>\n const tenantId =\n typeof data.tenantId === 'string' && data.tenantId.length > 0 ? data.tenantId : ctx.tenantId ?? null\n const organizationId =\n typeof data.organizationId === 'string' && data.organizationId.length > 0\n ? data.organizationId\n : ctx.organizationId ?? null\n return { tenantId, organizationId }\n}\n\nfunction readPromotionIdFromUsagePayload(payload: unknown): string | null {\n const data = (payload ?? {}) as Record<string, unknown>\n return typeof data.promotionId === 'string' && data.promotionId.length > 0 ? data.promotionId : null\n}\n\nfunction resolveSubscriberCache(ctx: PromotionsSubscriberContext): CacheStrategy | null {\n try {\n return ctx.resolve<CacheStrategy>('cache') ?? null\n } catch {\n return null\n }\n}\n\n// \u00A7Cache Build Contract item 5: the main tree cache is invalidated ONLY by promotion/\n// code mutation events (`promotions.promotion.created|updated|deleted`,\n// `promotions.code.created|updated|deleted`) \u2014 never by a usage event. Wired to those\n// six exact event ids (not a `promotions.promotion.*`/`promotions.code.*` wildcard) so\n// the lifecycle events declared alongside them in `events.ts`\n// (`promotions.promotion.budget_exhausted`/`.expiring_soon`, `promotions.code.used`/\n// `.exhausted`) never trigger a rebuild they were not asked to.\nexport async function handlePromotionTreeCacheInvalidation(\n payload: unknown,\n ctx: PromotionsSubscriberContext,\n): Promise<void> {\n const { tenantId, organizationId } = readSubscriberEventScope(payload, ctx)\n if (!tenantId || !organizationId) return\n const cache = resolveSubscriberCache(ctx)\n if (!cache) return\n try {\n await invalidatePromotionTreeCache(cache, tenantId, organizationId)\n } catch (err) {\n subscriberLogger.warn('[internal] promotion tree cache invalidation failed', { tenantId, organizationId, err })\n }\n}\n\n// \u00A7Cache Build Contract item 4/5: `promotions.usage.registered`/`.reverted` evict only\n// the one promotion's small counters-key entry, never the main tree cache.\nexport async function handlePromotionUsageCounterCacheInvalidation(\n payload: unknown,\n ctx: PromotionsSubscriberContext,\n): Promise<void> {\n const { tenantId, organizationId } = readSubscriberEventScope(payload, ctx)\n const promotionId = readPromotionIdFromUsagePayload(payload)\n if (!tenantId || !organizationId || !promotionId) return\n const cache = resolveSubscriberCache(ctx)\n if (!cache) return\n try {\n await invalidatePromotionUsageCounterCache(cache, tenantId, organizationId, promotionId)\n } catch (err) {\n subscriberLogger.warn('[internal] promotion usage counter cache invalidation failed', {\n tenantId,\n organizationId,\n promotionId,\n err,\n })\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["promotions"]
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.10.1-develop.7774.1.9a13ef7580",
3
+ "version": "0.10.1-develop.7775.1.2a1f553826",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -266,16 +266,16 @@
266
266
  "zod": "^4.4.3"
267
267
  },
268
268
  "peerDependencies": {
269
- "@open-mercato/ai-assistant": "0.10.1-develop.7774.1.9a13ef7580",
270
- "@open-mercato/shared": "0.10.1-develop.7774.1.9a13ef7580",
271
- "@open-mercato/ui": "0.10.1-develop.7774.1.9a13ef7580",
269
+ "@open-mercato/ai-assistant": "0.10.1-develop.7775.1.2a1f553826",
270
+ "@open-mercato/shared": "0.10.1-develop.7775.1.2a1f553826",
271
+ "@open-mercato/ui": "0.10.1-develop.7775.1.2a1f553826",
272
272
  "react": "^19.0.0",
273
273
  "react-dom": "^19.0.0"
274
274
  },
275
275
  "devDependencies": {
276
- "@open-mercato/ai-assistant": "0.10.1-develop.7774.1.9a13ef7580",
277
- "@open-mercato/shared": "0.10.1-develop.7774.1.9a13ef7580",
278
- "@open-mercato/ui": "0.10.1-develop.7774.1.9a13ef7580",
276
+ "@open-mercato/ai-assistant": "0.10.1-develop.7775.1.2a1f553826",
277
+ "@open-mercato/shared": "0.10.1-develop.7775.1.2a1f553826",
278
+ "@open-mercato/ui": "0.10.1-develop.7775.1.2a1f553826",
279
279
  "@testing-library/dom": "^10.4.1",
280
280
  "@testing-library/jest-dom": "^7.0.1",
281
281
  "@testing-library/react": "^16.3.3",
@@ -1,3 +1,4 @@
1
+ import { TransactionContext } from '@mikro-orm/core'
1
2
  import type { EntityManager } from '@mikro-orm/postgresql'
2
3
  import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
4
  import { CatalogProduct, CatalogProductVariant } from '@open-mercato/core/modules/catalog/data/entities'
@@ -163,7 +164,7 @@ export type BuildPromotionsEffectsPhaseParams = {
163
164
  export function buildPromotionsEffectsPhase(params: BuildPromotionsEffectsPhaseParams): () => Promise<void> {
164
165
  const { em, container, cart, lines, assortmentScope, tenantScope, now, adjustments, warnings, createdGiftLines, removedGiftLines } = params
165
166
 
166
- return async () => {
167
+ return () => TransactionContext.create(em, async () => {
167
168
  const promotionsService = container.resolve<PromotionsService>('promotionsService')
168
169
  const salesCalculationService = container.resolve<SalesCalculationService>('salesCalculationService')
169
170
 
@@ -181,11 +182,6 @@ export function buildPromotionsEffectsPhase(params: BuildPromotionsEffectsPhaseP
181
182
  const codeIdsInUse = Array.from(
182
183
  new Set(existingApplications.map((application) => application.codeId).filter((id): id is string => !!id)),
183
184
  )
184
- // A code-id's reservation lives on `promotions`' own tables, not this cart's `em` — so
185
- // extending it and then re-checking it still exists is two round trips against a
186
- // DIFFERENT EntityManager (`extendReservation`'s own `resolvePromotionsEm` fork) plus a
187
- // plain cross-module read on THIS `em`, never a read-after-mutate hazard on this phase's
188
- // own pending changes (there are none yet at this point in the phase).
189
185
  const lapsedCodeIds = new Set<string>()
190
186
  for (const codeId of codeIdsInUse) {
191
187
  await promotionsService.extendReservation(codeId, cart.id)
@@ -566,5 +562,5 @@ export function buildPromotionsEffectsPhase(params: BuildPromotionsEffectsPhaseP
566
562
  // has the full `appliedPromotions` shape to read from, same "found and closed before
567
563
  // anyone needed it" style this run's earlier steps use for a half-wired field.
568
564
  cart.promotionEffects = result.appliedPromotions
569
- }
565
+ })
570
566
  }
@@ -1,3 +1,4 @@
1
+ import { TransactionContext } from '@mikro-orm/core'
1
2
  import type { EntityManager } from '@mikro-orm/postgresql'
2
3
  import { computeProductScopeKeys, PRODUCT_SCOPE_CATEGORY_PREFIX } from '@open-mercato/core/modules/catalog/lib/productScopeKeys'
3
4
  import { Currency } from '@open-mercato/core/modules/currencies/data/entities'
@@ -91,9 +92,10 @@ function resolvePromotionsServiceScope(container: PromotionsServiceContainer): P
91
92
  }
92
93
 
93
94
  function resolvePromotionsEm(container: PromotionsServiceContainer): EntityManager {
94
- // Same fork-on-resolve precedent as `services/promotion-cache.ts`'s
95
- // `resolvePromotionEm` / `commands/promotions.ts`'s command handlers.
96
- return container.resolve<EntityManager>('em').fork()
95
+ const registeredEm = container.resolve<EntityManager>('em')
96
+ const contextualEm = TransactionContext.getEntityManager(registeredEm.name) as EntityManager | undefined
97
+ const em = contextualEm && contextualEm.getDriver() === registeredEm.getDriver() ? contextualEm : registeredEm
98
+ return em.fork({ keepTransactionContext: true, disableContextResolution: Boolean(contextualEm && contextualEm !== em) })
97
99
  }
98
100
 
99
101
  // Step 6.2's five code-service methods need the full `auth` (not just tenant/org scope)
@@ -1,3 +1,4 @@
1
+ import { TransactionContext } from '@mikro-orm/core'
1
2
  import type { EntityManager } from '@mikro-orm/postgresql'
2
3
  import { runWithCacheTenant, type CacheStrategy } from '@open-mercato/cache'
3
4
  import { createLogger } from '@open-mercato/shared/lib/logger'
@@ -284,6 +285,7 @@ export type PromotionCacheContainer = {
284
285
  }
285
286
 
286
287
  function resolvePromotionCacheStrategy(container: PromotionCacheContainer): CacheStrategy | null {
288
+ if (resolvePromotionEm(container).isInTransaction()) return null
287
289
  try {
288
290
  return container.resolve<CacheStrategy>('cache') ?? null
289
291
  } catch {
@@ -292,15 +294,21 @@ function resolvePromotionCacheStrategy(container: PromotionCacheContainer): Cach
292
294
  }
293
295
 
294
296
  function resolvePromotionEm(container: PromotionCacheContainer): EntityManager {
295
- // Exact precedent: `commands/promotions.ts` resolves/forks the same way —
296
- // `(ctx.container.resolve('em') as EntityManager).fork()` — before any read/write.
297
- return (container.resolve<EntityManager>('em')).fork()
297
+ const registeredEm = container.resolve<EntityManager>('em')
298
+ const contextualEm = TransactionContext.getEntityManager(registeredEm.name) as EntityManager | undefined
299
+ const em = contextualEm && contextualEm.getDriver() === registeredEm.getDriver() ? contextualEm : registeredEm
300
+ return em.fork({ keepTransactionContext: true, disableContextResolution: Boolean(contextualEm && contextualEm !== em) })
298
301
  }
299
302
 
300
303
  export class PromotionCacheService {
301
304
  constructor(private readonly container: PromotionCacheContainer) {}
302
305
 
303
306
  async getActivePromotions(tenantId: string, organizationId: string): Promise<NormalizedPromotion[]> {
307
+ const em = resolvePromotionEm(this.container)
308
+ if (em.isInTransaction()) {
309
+ const promotions = await this.buildActivePromotionTrees(tenantId, organizationId)
310
+ return this.buildTransactionalUsageSnapshot(em, promotions, tenantId, organizationId)
311
+ }
304
312
  const cache = resolvePromotionCacheStrategy(this.container)
305
313
  const treeKey = buildPromotionTreeCacheKey(tenantId, organizationId)
306
314
 
@@ -317,6 +325,35 @@ export class PromotionCacheService {
317
325
  )
318
326
  }
319
327
 
328
+ private async buildTransactionalUsageSnapshot(
329
+ em: EntityManager,
330
+ promotions: NormalizedPromotion[],
331
+ tenantId: string,
332
+ organizationId: string,
333
+ ): Promise<NormalizedPromotion[]> {
334
+ const budgeted = promotions.filter((promotion) => promotion.maxBudget !== null)
335
+ const currencies = [...new Set(budgeted.map((promotion) => promotion.budgetCurrency).filter((currency): currency is string => !!currency))]
336
+ const totals = new Map<string, string>()
337
+ if (budgeted.length > 0 && currencies.length > 0) {
338
+ const promotionIds = budgeted.map((promotion) => promotion.id)
339
+ const promotionSlots = promotionIds.map(() => '?').join(', ')
340
+ const currencySlots = currencies.map(() => '?').join(', ')
341
+ const rows = await em.execute<Array<{ promotion_id: string; currency: string; total: string }>>(
342
+ `SELECT promotion_id, currency, COALESCE(SUM(total_discount_amount), 0)::text AS total
343
+ FROM promotion_usages
344
+ WHERE promotion_id IN (${promotionSlots}) AND currency IN (${currencySlots})
345
+ AND organization_id = ? AND tenant_id = ? AND reverted_at IS NULL
346
+ GROUP BY promotion_id, currency`,
347
+ [...promotionIds, ...currencies, organizationId, tenantId],
348
+ )
349
+ for (const row of rows) totals.set(`${row.promotion_id}:${row.currency}`, row.total)
350
+ }
351
+ return promotions.map((promotion) => promotion.maxBudget === null ? promotion : freezeOutsideProduction({
352
+ ...promotion,
353
+ totalDiscountGranted: totals.get(`${promotion.id}:${promotion.budgetCurrency}`) ?? '0',
354
+ }))
355
+ }
356
+
320
357
  // §Cache Build Contract item 4: "read only for budget-capped promotions" — an
321
358
  // uncapped promotion's `totalDiscountGranted` is never read back from
322
359
  // `promotion_usages`, it stays the inert '0' baked into the tree cache.
@@ -369,7 +406,7 @@ export class PromotionCacheService {
369
406
  ): Promise<string> {
370
407
  if (!budgetCurrency) return '0'
371
408
  const em = resolvePromotionEm(this.container)
372
- const rows = (await em.getConnection().execute(
409
+ const rows = (await em.execute(
373
410
  `SELECT COALESCE(SUM(total_discount_amount), 0)::text AS total
374
411
  FROM promotion_usages
375
412
  WHERE promotion_id = ? AND organization_id = ? AND tenant_id = ? AND currency = ? AND reverted_at IS NULL`,