@open-mercato/core 0.10.1-develop.7774.1.9a13ef7580 → 0.10.1-develop.7776.1.3032ea0741

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,4 +1,4 @@
1
- [build:core] found 6205 entry points
1
+ [build:core] found 6207 entry points
2
2
  [build:core] built successfully
3
3
  [build:core:generated] found 271 entry points
4
4
  [build:core:generated] built successfully
@@ -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": "AACA,SAAS,0BAA0B;AACnC,SAAS,gBAAgB,6BAA6B;AAItD,SAAS,uBAAuB;AAWhC,SAAe,UAAU,gCAAgC;AACzD,SAAS,mBAAmB,wBAAwB,iBAAiB;AAwC9D,SAAS,oBAAoB,MAA8B;AAChE,SAAO;AAAA,IACL,QAAQ,KAAK;AAAA,IACb,WAAW,KAAK;AAAA,IAChB,WAAW,KAAK,aAAa;AAAA,IAC7B,UAAU,KAAK;AAAA,IACf,cAAc,KAAK,gBAAgB;AAAA,IACnC,gBAAgB,KAAK,kBAAkB;AAAA,IACvC,oBAAoB;AAAA,EACtB;AACF;AAsBA,SAAS,gBAAgB,QAAgC,QAA+B;AACtF,MAAI,OAAO,MAAM,EAAG,QAAO,OAAO,MAAM;AACxC,QAAM,CAAC,QAAQ,IAAI,OAAO,MAAM,GAAG;AACnC,MAAI,YAAY,OAAO,QAAQ,EAAG,QAAO,OAAO,QAAQ;AACxD,QAAM,SAAS,OAAO,OAAO,MAAM;AACnC,SAAO,OAAO,SAAS,IAAI,OAAO,CAAC,IAAI;AACzC;AAEA,SAAS,YAAY,aAAqB,WAAmB,WAAkC;AAC7F,SAAO,GAAG,WAAW,IAAI,SAAS,IAAI,aAAa,EAAE;AACvD;AAoBA,MAAM,kBAA+B,EAAE,MAAM,MAAM,QAAQ,MAAM,UAAU,MAAM,mBAAmB,KAAK;AA0ClG,SAAS,4BAA4B,QAAgE;AAC1G,QAAM,EAAE,IAAI,WAAW,MAAM,OAAO,iBAAiB,aAAa,KAAK,aAAa,UAAU,kBAAkB,iBAAiB,IAAI;AAErI,SAAO,YAAY;AACjB,UAAM,oBAAoB,UAAU,QAA2B,mBAAmB;AAClF,UAAM,0BAA0B,UAAU,QAAiC,yBAAyB;AAKpG,UAAM,uBAAwB,MAAM;AAAA,MAClC;AAAA,MACA;AAAA,MACA,EAAE,QAAQ,KAAK,IAAI,UAAU,KAAK,UAAU,gBAAgB,KAAK,eAAe;AAAA,MAChF,CAAC;AAAA,MACD,EAAE,UAAU,KAAK,UAAU,gBAAgB,KAAK,eAAe;AAAA,IACjE;AAEA,UAAM,eAAe,MAAM;AAAA,MACzB,IAAI,IAAI,qBAAqB,IAAI,CAAC,gBAAgB,YAAY,MAAM,EAAE,OAAO,CAAC,OAAqB,CAAC,CAAC,EAAE,CAAC;AAAA,IAC1G;AAMA,UAAM,gBAAgB,oBAAI,IAAY;AACtC,eAAW,UAAU,cAAc;AACjC,YAAM,kBAAkB,kBAAkB,QAAQ,KAAK,EAAE;AACzD,YAAM,gBAAgB,MAAM,GAAG,QAAQ,iBAAiB;AAAA,QACtD;AAAA,QACA,QAAQ,KAAK;AAAA,QACb,UAAU,YAAY;AAAA,QACtB,gBAAgB,YAAY;AAAA,MAC9B,CAAC;AACD,UAAI,CAAC,cAAe,eAAc,IAAI,MAAM;AAAA,IAC9C;AAKA,UAAM,gBAAgB,oBAAI,IAAqB;AAC/C,UAAM,yBAAyB,oBAAI,IAAyB;AAC5D,eAAW,eAAe,sBAAsB;AAC9C,UAAI,CAAC,YAAY,OAAQ;AACzB,UAAI,CAAC,cAAc,IAAI,YAAY,MAAM,GAAG;AAC1C,sBAAc,IAAI,YAAY,QAAQ;AAAA,UACpC,IAAI,YAAY;AAAA,UAChB,MAAO,YAAY,YAAY;AAAA,QACjC,CAAC;AAAA,MACH;AAKA,UAAI,CAAC,uBAAuB,IAAI,YAAY,WAAW,GAAG;AACxD,+BAAuB,IAAI,YAAY,aAAa;AAAA,UAClD,MAAM,YAAY,QAAQ;AAAA,UAC1B,QAAQ,YAAY;AAAA,UACpB,UAAU,YAAY,YAAY;AAAA,UAClC,mBAAmB,YAAY,qBAAqB;AAAA,QACtD,CAAC;AAAA,MACH;AAAA,IACF;AACA,UAAM,QAAQ,MAAM,KAAK,cAAc,OAAO,CAAC;AAI/C,UAAM,iBAAiB,MAAM,OAAO,CAAC,SAAS,KAAK,aAAa,QAAQ,KAAK,WAAW,WAAW;AACnG,UAAM,QAAsB;AAAA,MAC1B,YAAY,KAAK,cAAc;AAAA,MAC/B,aAAa,KAAK,eAAe,CAAC;AAAA,MAClC,kBAAkB,KAAK;AAAA,IACzB;AAEA,UAAM,SAAS,MAAM,kBAAkB,SAAS;AAAA,MAC9C,OAAO,eAAe,IAAI,mBAAmB;AAAA,MAC7C,UAAU,KAAK;AAAA,MACf,aAAa,IAAI,YAAY;AAAA,MAC7B;AAAA;AAAA;AAAA;AAAA,MAIA,oBAAoB;AAAA,MACpB,YAAY;AAAA,MACZ;AAAA,IACF,CAAC;AAGD,UAAM,WAAW,IAAI,IAAI,MAAM,IAAI,CAAC,SAAS,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC;AAC7D,UAAM,sBAAkG,CAAC;AACzG,UAAM,sBAAkF,CAAC;AACzF,UAAM,kBAA6E,CAAC;AAEpF,eAAW,WAAW,OAAO,mBAAmB;AAC9C,iBAAW,UAAU,QAAQ,SAAS;AACpC,YAAI,OAAO,SAAS,iBAAiB;AACnC,gBAAM,SAAS,SAAS,IAAI,OAAO,YAAY;AAC/C,cAAI,OAAQ,qBAAoB,KAAK,EAAE,aAAa,QAAQ,aAAa,MAAM,QAAQ,OAAO,CAAC;AAAA,QACjG,WAAW,OAAO,SAAS,iBAAiB;AAC1C,8BAAoB,KAAK,EAAE,aAAa,QAAQ,aAAa,OAAO,CAAC;AAAA,QACvE,WAAW,OAAO,SAAS,iBAAiB;AAC1C,0BAAgB,KAAK,EAAE,aAAa,QAAQ,aAAa,OAAO,CAAC;AAAA,QACnE;AAAA,MAEF;AAAA,IACF;AAEA,UAAM,oBAAoB,MAAM,OAAO,CAAC,SAAS,KAAK,WAAW,WAAW;AAC5E,UAAM,oBAAoB,IAAI;AAAA,MAC5B,kBAAkB,IAAI,CAAC,SAAS,CAAC,YAAY,KAAK,aAAuB,KAAK,WAAW,KAAK,aAAa,IAAI,GAAG,IAAI,CAAC;AAAA,IACzH;AACA,UAAM,WAAW,IAAI;AAAA,MACnB,gBAAgB,IAAI,CAAC,EAAE,aAAa,OAAO,MAAM,YAAY,aAAa,OAAO,WAAW,OAAO,aAAa,IAAI,CAAC;AAAA,IACvH;AACA,UAAM,oBAAoB,MAAM,KAAK,kBAAkB,QAAQ,CAAC,EAC7D,OAAO,CAAC,CAAC,GAAG,MAAM,CAAC,SAAS,IAAI,GAAG,CAAC,EACpC,IAAI,CAAC,CAAC,EAAE,IAAI,MAAM,IAAI;AACzB,UAAM,qBAAqB,gBAAgB;AAAA,MACzC,CAAC,EAAE,aAAa,OAAO,MAAM,CAAC,kBAAkB,IAAI,YAAY,aAAa,OAAO,WAAW,OAAO,aAAa,IAAI,CAAC;AAAA,IAC1H;AAIA,QAAI,aAAmC,oBAAI,IAAI;AAC/C,QAAI,eAAe,oBAAI,IAA4B;AACnD,QAAI,eAAe,oBAAI,IAAmC;AAC1D,QAAI,iBAAiB;AACrB,QAAI,mBAAmB,SAAS,GAAG;AACjC,YAAM,iBAAiB,mBAAmB,IAAI,CAAC,EAAE,OAAO,OAAO,EAAE,WAAW,OAAO,WAAW,WAAW,OAAO,aAAa,KAAK,EAAE;AACpI,mBAAa,MAAM,uBAAuB,EAAE,OAAO,gBAAgB,OAAO,iBAAiB,aAAa,IAAI,UAAU,CAAC;AAEvH,YAAM,aAAa,MAAM,KAAK,IAAI,IAAI,mBAAmB,IAAI,CAAC,EAAE,OAAO,MAAM,OAAO,SAAS,CAAC,CAAC;AAC/F,YAAM,WAAY,MAAM;AAAA,QACtB;AAAA,QACA;AAAA,QACA,EAAE,IAAI,EAAE,KAAK,WAAW,GAAG,UAAU,YAAY,UAAU,gBAAgB,YAAY,gBAAgB,WAAW,KAAK;AAAA,QACvH,CAAC;AAAA,QACD;AAAA,MACF;AACA,qBAAe,IAAI,IAAI,SAAS,IAAI,CAAC,YAAY,CAAC,QAAQ,IAAI,OAAO,CAAC,CAAC;AAEvE,YAAM,aAAa,MAAM;AAAA,QACvB,IAAI,IAAI,mBAAmB,IAAI,CAAC,EAAE,OAAO,MAAM,OAAO,SAAS,EAAE,OAAO,CAAC,OAAqB,CAAC,CAAC,EAAE,CAAC;AAAA,MACrG;AACA,UAAI,WAAW,SAAS,GAAG;AACzB,cAAM,WAAY,MAAM;AAAA,UACtB;AAAA,UACA;AAAA,UACA,EAAE,IAAI,EAAE,KAAK,WAAW,GAAG,UAAU,YAAY,UAAU,gBAAgB,YAAY,gBAAgB,WAAW,KAAK;AAAA,UACvH,CAAC;AAAA,UACD;AAAA,QACF;AACA,uBAAe,IAAI,IAAI,SAAS,IAAI,CAAC,YAAY,CAAC,QAAQ,IAAI,OAAO,CAAC,CAAC;AAAA,MACzE;AAKA,YAAM,uBAAuB,MAAM,GAAG,KAAK,UAAU;AAAA,QACnD,QAAQ,KAAK;AAAA,QACb,UAAU,YAAY;AAAA,QACtB,gBAAgB,YAAY;AAAA,MAC9B,CAAC;AACD,uBAAiB,qBAAqB,OAAO,CAAC,KAAK,SAAS,KAAK,IAAI,KAAK,KAAK,UAAU,GAAG,CAAC,IAAI;AAAA,IACnG;AAUA,eAAW,QAAQ,gBAAgB;AACjC,UAAI,KAAK,qBAAqB,KAAM,MAAK,mBAAmB;AAAA,IAC9D;AACA,eAAW,EAAE,MAAM,OAAO,KAAK,qBAAqB;AAClD,WAAK,mBAAmB,OAAO;AAAA,IACjC;AAEA,eAAW,YAAY,mBAAmB;AACxC,eAAS,YAAY;AACrB,YAAM,QAAQ,MAAM,QAAQ,QAAQ;AACpC,UAAI,SAAS,EAAG,OAAM,OAAO,OAAO,CAAC;AACrC,uBAAiB,KAAK,QAAQ;AAAA,IAChC;AAEA,UAAM,uBAAuB,oBAAI,IAAsB;AACvD,eAAW,EAAE,aAAa,OAAO,KAAK,oBAAoB;AACxD,YAAM,MAAM,EAAE,WAAW,OAAO,WAAW,WAAW,OAAO,aAAa,KAAK;AAC/E,YAAM,UAAU,UAAU,WAAW,IAAI,kBAAkB,GAAG,CAAC,KAAK,aAAa;AACjF,UAAI,CAAC,SAAS;AACZ,iBAAS,KAAK,EAAE,MAAM,uBAAuB,WAAW,OAAO,WAAW,WAAW,OAAO,aAAa,KAAK,CAAC;AAC/G;AAAA,MACF;AACA,YAAM,UAAU,aAAa,IAAI,OAAO,SAAS,KAAK;AACtD,UAAI,CAAC,SAAS;AAEZ,iBAAS,KAAK,EAAE,MAAM,uBAAuB,WAAW,OAAO,WAAW,WAAW,OAAO,aAAa,KAAK,CAAC;AAC/G;AAAA,MACF;AACA,YAAM,UAAU,OAAO,YAAY,aAAa,IAAI,OAAO,SAAS,KAAK,OAAO;AAKhF,YAAM,iBAAiB,MAAM,wBAAwB,cAAc;AAAA,QACjE,cAAc;AAAA,QACd,MAAM;AAAA,UACJ,MAAM;AAAA,UACN,WAAW,QAAQ;AAAA,UACnB,kBAAkB,SAAS,MAAM;AAAA,UACjC,MAAM,QAAQ;AAAA,UACd,UAAU,OAAO;AAAA,UACjB,cAAc,KAAK;AAAA,UACnB,cAAc;AAAA,UACd,gBAAgB;AAAA,UAChB,SAAS;AAAA,UACT,eAAe;AAAA,QACjB;AAAA,QACA,SAAS,EAAE,UAAU,KAAK,UAAU,gBAAgB,KAAK,gBAAgB,cAAc,KAAK,aAAa;AAAA,MAC3G,CAAC;AAED,YAAM,WAAW,GAAG,OAAO,UAAU;AAAA,QACnC,gBAAgB,KAAK;AAAA,QACrB,UAAU,KAAK;AAAA,QACf,QAAQ,KAAK;AAAA,QACb,YAAY;AAAA,QACZ,WAAW,QAAQ;AAAA,QACnB,WAAW,SAAS,MAAM;AAAA,QAC1B,KAAK,SAAS,OAAO,QAAQ,OAAO;AAAA,QACpC,MAAM,QAAQ;AAAA,QACd,cAAc,SAAS,gBAAgB;AAAA,QACvC,UAAU,OAAO,OAAO,QAAQ;AAAA,QAChC,cAAc;AAAA,QACd,gBAAgB;AAAA,QAChB,SAAS;AAAA,QACT,QAAQ;AAAA,QACR;AAAA,QACA,kBAAkB;AAAA,QAClB,WAAW;AAAA,QACX,eAAe;AAAA,QACf,kBAAkB;AAAA,QAClB,gBAAgB;AAAA,QAChB,UAAU;AAAA,QACV,UAAU;AAAA,QACV,YAAY;AAAA,MACd,CAAC;AACD,wBAAkB;AAClB,SAAG,QAAQ,QAAQ;AACnB,YAAM,KAAK,QAAQ;AACnB,uBAAiB,KAAK,QAAQ;AAC9B,2BAAqB,IAAI,YAAY,aAAa,OAAO,WAAW,OAAO,aAAa,IAAI,GAAG,QAAQ;AAAA,IACzG;AAIA,UAAM,0BAA0B,oBAAI,IAAgC;AACpE,UAAM,kBAAkB,CAAC,aAAqB,UAAkC;AAC9E,YAAM,UAAU,wBAAwB,IAAI,WAAW,KAAK,CAAC;AAC7D,cAAQ,KAAK,KAAK;AAClB,8BAAwB,IAAI,aAAa,OAAO;AAAA,IAClD;AAEA,eAAW,EAAE,aAAa,MAAM,OAAO,KAAK,qBAAqB;AAC/D,sBAAgB,aAAa,EAAE,YAAY,KAAK,IAAI,QAAQ,WAAW,OAAO,WAAW,aAAa,OAAO,YAAY,CAAC;AAAA,IAC5H;AACA,eAAW,EAAE,aAAa,OAAO,KAAK,qBAAqB;AACzD,kBAAY,KAAK;AAAA,QACf,OAAO;AAAA,QACP,MAAM;AAAA,QACN;AAAA,QACA,WAAW,OAAO,OAAO,SAAS;AAAA,QAClC,aAAa,OAAO,OAAO,WAAW;AAAA,QACtC,cAAc,OAAO;AAAA,QACrB,OAAO,gBAAgB,OAAO,OAAO,KAAK,MAAM;AAAA,MAClD,CAAC;AACD,sBAAgB,aAAa,EAAE,YAAY,MAAM,QAAQ,WAAW,OAAO,WAAW,aAAa,OAAO,YAAY,CAAC;AAAA,IACzH;AACA,eAAW,EAAE,aAAa,OAAO,KAAK,oBAAoB;AACxD,YAAM,cAAc,qBAAqB,IAAI,YAAY,aAAa,OAAO,WAAW,OAAO,aAAa,IAAI,CAAC;AAEjH,UAAI,CAAC,YAAa;AAClB,sBAAgB,aAAa,EAAE,YAAY,YAAY,IAAI,QAAQ,WAAW,MAAM,aAAa,KAAK,CAAC;AAAA,IACzG;AAqCA,UAAM,sBAAsB,IAAI,IAAI,OAAO,kBAAkB,IAAI,CAAC,YAAY,QAAQ,WAAW,CAAC;AAClG,UAAM,sBAAsB,oBAAI,IAAY;AAC5C,eAAW,CAAC,aAAa,OAAO,KAAK,yBAAyB;AAC5D,iBAAW,SAAS,SAAS;AAC3B,4BAAoB,IAAI,GAAG,WAAW,IAAI,MAAM,cAAc,EAAE,EAAE;AAAA,MACpE;AAAA,IACF;AACA,eAAW,uBAAuB,sBAAsB;AACtD,YAAM,SAAS,oBAAoB,UAAU;AAE7C,UAAI,UAAU,QAAQ,cAAc,IAAI,MAAM,GAAG;AAM/C,WAAG,OAAO,mBAAmB;AAC7B,iBAAS,KAAK,EAAE,MAAM,6BAA6B,aAAa,oBAAoB,YAAY,CAAC;AACjG;AAAA,MACF;AAEA,YAAM,MAAM,GAAG,oBAAoB,WAAW,IAAI,oBAAoB,cAAc,EAAE;AACtF,UAAI,oBAAoB,IAAI,oBAAoB,WAAW,KAAK,oBAAoB,IAAI,GAAG,GAAG;AAE5F;AAAA,MACF;AAEA,UAAI,CAAC,oBAAoB,IAAI,oBAAoB,WAAW,KAAK,UAAU,MAAM;AAE/E,4BAAoB,SAAS,CAAC;AAC9B,4BAAoB,YAAY;AAChC,4BAAoB,cAAc;AAClC,4BAAoB,aAAa;AACjC;AAAA,MACF;AAEA,SAAG,OAAO,mBAAmB;AAAA,IAC/B;AAEA,eAAW,CAAC,aAAa,OAAO,KAAK,yBAAyB;AAC5D,iBAAW,SAAS,SAAS;AAC3B,cAAM,UAAU,qBAAqB;AAAA,UACnC,CAACA,iBACCA,aAAY,gBAAgB,gBAAgBA,aAAY,cAAc,UAAU,MAAM;AAAA,QAC1F;AACA,YAAI,SAAS;AACX,kBAAQ,YAAY,MAAM;AAC1B,kBAAQ,cAAc,MAAM;AAC5B,kBAAQ,SAAS,MAAM;AACvB,kBAAQ,YAAY;AACpB;AAAA,QACF;AAKA,cAAM,UAAU,uBAAuB,IAAI,WAAW,KAAK;AAC3D,cAAM,cAAc,GAAG,OAAO,0BAA0B;AAAA,UACtD,gBAAgB,KAAK;AAAA,UACrB,UAAU,KAAK;AAAA,UACf,QAAQ,KAAK;AAAA,UACb;AAAA,UACA,MAAM,QAAQ;AAAA,UACd,QAAQ,QAAQ;AAAA,UAChB,UAAU,QAAQ;AAAA,UAClB,mBAAmB,QAAQ;AAAA,UAC3B,OAAO,MAAM,aAAc,SAAoB;AAAA,UAC/C,YAAY,MAAM;AAAA,UAClB,QAAQ,MAAM;AAAA,UACd,WAAW,MAAM;AAAA,UACjB,aAAa,MAAM;AAAA,UACnB,WAAW;AAAA,QACb,CAAC;AACD,WAAG,QAAQ,WAAW;AAAA,MACxB;AAAA,IACF;AAOA,SAAK,mBAAmB,OAAO;AAAA,EACjC;AACF;",
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": "AACA,SAAS,yBAAyB,qCAAqC;AACvE,SAAS,gBAAgB;AAEzB,SAAS,qBAAqB;AAC9B,SAAS,0BAA0B;AAsEnC,SAAS,8BAA8B,WAA+D;AACpG,MAAI,OAAqC;AACzC,MAAI;AACF,WAAO,UAAU,QAAsC,MAAM;AAAA,EAC/D,QAAQ;AACN,WAAO;AAAA,EACT;AACA,QAAM,WAAW,MAAM,YAAY;AACnC,QAAM,iBAAiB,MAAM,SAAS;AACtC,MAAI,CAAC,YAAY,CAAC,gBAAgB;AAChC,UAAM,IAAI,cAAc,KAAK;AAAA,MAC3B,OAAO;AAAA,IACT,CAAC;AAAA,EACH;AACA,SAAO,EAAE,UAAU,eAAe;AACpC;AAEA,SAAS,oBAAoB,WAAsD;AAGjF,SAAO,UAAU,QAAuB,IAAI,EAAE,KAAK;AACrD;AAOA,SAAS,qCAAqC,WAG5C;AACA,MAAI,OAAqC;AACzC,MAAI;AACF,WAAO,UAAU,QAAsC,MAAM;AAAA,EAC/D,QAAQ;AACN,WAAO;AAAA,EACT;AACA,QAAM,WAAW,MAAM,YAAY;AACnC,QAAM,iBAAiB,MAAM,SAAS;AACtC,MAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,gBAAgB;AACzC,UAAM,IAAI,cAAc,KAAK;AAAA,MAC3B,OAAO;AAAA,IACT,CAAC;AAAA,EACH;AACA,SAAO,EAAE,MAAM,OAAO,EAAE,UAAU,eAAe,EAAE;AACrD;AASA,SAAS,8BACP,WACA,MACA,OACuB;AACvB,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,mBAAmB;AAAA,IACnB,wBAAwB,MAAM;AAAA,IAC9B,iBAAiB,CAAC,MAAM,cAAc;AAAA,EACxC;AACF;AAEA,SAAS,4BAA4B,WAAmD;AACtF,SAAO,UAAU,QAAoB,YAAY;AACnD;AAEA,SAAS,4BAA4B,WAA6D;AAChG,SAAO,UAAU,QAA8B,sBAAsB;AACvE;AAQA,eAAe,8BACb,WACA,YACA,OACgC;AAChC,QAAM,SAAS,oBAAI,IAAsB;AACzC,MAAI,WAAW,WAAW,EAAG,QAAO;AACpC,QAAM,KAAK,oBAAoB,SAAS;AACxC,QAAM,KAAK,GAAG,UAAe;AAC7B,QAAM,YAAY,MAAM,wBAAwB,IAAI,YAAY,KAAK;AACrE,aAAW,CAAC,WAAW,IAAI,KAAK,WAAW;AACzC,UAAM,cAAc,KACjB,OAAO,CAAC,QAAQ,IAAI,WAAW,6BAA6B,CAAC,EAC7D,IAAI,CAAC,QAAQ,IAAI,MAAM,8BAA8B,MAAM,CAAC;AAC/D,WAAO,IAAI,WAAW,WAAW;AAAA,EACnC;AACA,SAAO;AACT;AAUA,SAAS,4BAA4B,YAA4C;AAC/E,SAAO,CAAC;AACV;AAEA,SAAS,mBACP,OACA,sBACwB;AACxB,SAAO,MAAM,IAAI,CAAC,UAAU;AAAA,IAC1B,GAAG;AAAA,IACH,aAAa,qBAAqB,IAAI,KAAK,SAAS,KAAK,CAAC;AAAA,IAC1D,YAAY,4BAA4B,KAAK,SAAS;AAAA,EACxD,EAAE;AACJ;AAEA,eAAe,oBACb,WACA,OACA,OACiC;AACjC,QAAM,aAAa,MAAM,KAAK,IAAI,IAAI,MAAM,IAAI,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC;AAC1E,QAAM,uBAAuB,MAAM,8BAA8B,WAAW,YAAY,KAAK;AAC7F,SAAO,mBAAmB,OAAO,oBAAoB;AACvD;AAQA,eAAe,8BACb,WACA,UACA,OAC6B;AAC7B,MAAI,CAAC,SAAU,QAAO;AACtB,MAAI;AACF,UAAM,KAAK,oBAAoB,SAAS;AACxC,UAAM,MAAM,MAAM,GAAG,QAAQ,UAAU;AAAA,MACrC,MAAM;AAAA,MACN,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM;AAAA,MACtB,WAAW;AAAA,IACb,CAAC;AACD,WAAO,KAAK,iBAAiB;AAAA,EAC/B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,4BAA4B,OAIZ;AACvB,SAAO;AAAA,IACL,QAAQ;AAAA,IACR,WAAW,MAAM;AAAA,IACjB,WAAW,MAAM;AAAA,IACjB,UAAU;AAAA;AAAA;AAAA,IAGV,cAAc;AAAA,IACd,gBAAgB;AAAA,IAChB,oBAAoB;AAAA,IACpB,aAAa,MAAM;AAAA,IACnB,YAAY,4BAA4B,MAAM,SAAS;AAAA,EACzD;AACF;AAEO,MAAM,sBAAmD;AAAA,EAC9D,YAA6B,WAAuC;AAAvC;AAAA,EAAwC;AAAA,EAErE,MAAM,SAAS,OAA+C;AAC5D,UAAM,QAAQ,8BAA8B,KAAK,SAAS;AAC1D,UAAM,wBAAwB,KAAK,UAAU,QAA+B,uBAAuB;AACnG,UAAM,aAAa,MAAM,sBAAsB,oBAAoB,MAAM,UAAU,MAAM,cAAc;AAEvG,UAAM,QAAQ,MAAM,oBAAoB,KAAK,WAAW,MAAM,OAAO,KAAK;AAC1E,UAAM,yBAAyB,MAAM,8BAA8B,KAAK,WAAW,MAAM,UAAU,KAAK;AAQxG,UAAM,kBAAmC;AAAA,MACvC;AAAA,MACA,UAAU,MAAM;AAAA,MAChB,aAAa,MAAM;AAAA,MACnB,OAAO,MAAM;AAAA,MACb,oBAAoB,MAAM;AAAA,MAC1B,YAAY,MAAM;AAAA,MAClB,OAAO,MAAM;AAAA,MACb,GAAI,2BAA2B,SAAY,EAAE,uBAAuB,IAAI,CAAC;AAAA,IAC3E;AAEA,WAAO,mBAAmB,YAAY,eAAe;AAAA,EACvD;AAAA,EAEA,MAAM,mBAAmB,OAKqF;AAC5G,UAAM,QAAQ,8BAA8B,KAAK,SAAS;AAC1D,UAAM,wBAAwB,KAAK,UAAU,QAA+B,uBAAuB;AACnG,UAAM,aAAa,MAAM,sBAAsB,oBAAoB,MAAM,UAAU,MAAM,cAAc;AAEvG,UAAM,uBAAuB,MAAM,8BAA8B,KAAK,WAAW,CAAC,MAAM,SAAS,GAAG,KAAK;AACzG,UAAM,OAAO,4BAA4B;AAAA,MACvC,WAAW,MAAM;AAAA,MACjB,WAAW,MAAM;AAAA,MACjB,aAAa,qBAAqB,IAAI,MAAM,SAAS,KAAK,CAAC;AAAA,IAC7D,CAAC;AASD,UAAM,kBAAmC;AAAA,MACvC,OAAO,CAAC,IAAI;AAAA,MACZ,UAAU;AAAA,MACV,aAAa,MAAM;AAAA,MACnB,OAAO,MAAM;AAAA,MACb,oBAAoB;AAAA,MACpB,YAAY;AAAA,MACZ,OAAO,CAAC;AAAA,IACV;AAEA,UAAM,SAAS,mBAAmB,YAAY,eAAe;AAC7D,WAAO;AAAA,MACL,QAAQ,OAAO,kBAAkB,IAAI,CAAC,aAAa;AAAA,QACjD,aAAa,QAAQ;AAAA,QACrB,eAAe,QAAQ;AAAA;AAAA;AAAA;AAAA,QAIvB,OAAO,QAAQ,QAAQ,CAAC,GAAG,SAAS,CAAC;AAAA,MACvC,EAAE;AAAA,IACJ;AAAA,EACF;AAAA;AAAA;AAAA,EAIA,MAAM,YAAY,OAAyD;AACzE,UAAM,EAAE,MAAM,MAAM,IAAI,qCAAqC,KAAK,SAAS;AAC3E,UAAM,aAAa,4BAA4B,KAAK,SAAS;AAC7D,UAAM,MAAM,8BAA8B,KAAK,WAAW,MAAM,KAAK;AACrE,UAAM,EAAE,OAAO,IAAI,MAAM,WAAW;AAAA,MAClC;AAAA,MACA,EAAE,OAAO,IAAI;AAAA,IACf;AACA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA,EAIA,MAAM,kBAAkB,QAAgB,QAA+B;AACrE,UAAM,EAAE,MAAM,IAAI,qCAAqC,KAAK,SAAS;AACrE,UAAM,KAAK,oBAAoB,KAAK,SAAS;AAC7C,UAAM,4BAA4B,KAAK,SAAS,EAAE,kBAAkB,IAAI,QAAQ,QAAQ,KAAK;AAAA,EAC/F;AAAA;AAAA,EAGA,MAAM,aAAa,OAAwD;AACzE,UAAM,EAAE,MAAM,IAAI,qCAAqC,KAAK,SAAS;AACrE,UAAM,KAAK,oBAAoB,KAAK,SAAS;AAC7C,WAAO,4BAA4B,KAAK,SAAS,EAAE,aAAa,IAAI,OAAO,KAAK;AAAA,EAClF;AAAA;AAAA,EAGA,MAAM,QAAQ,OAA6C;AACzD,UAAM,EAAE,MAAM,MAAM,IAAI,qCAAqC,KAAK,SAAS;AAC3E,UAAM,aAAa,4BAA4B,KAAK,SAAS;AAC7D,UAAM,MAAM,8BAA8B,KAAK,WAAW,MAAM,KAAK;AACrE,UAAM,EAAE,OAAO,IAAI,MAAM,WAAW;AAAA,MAClC;AAAA,MACA,EAAE,OAAO,IAAI;AAAA,IACf;AACA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,YAAY,QAAgB,QAA+B;AAC/D,UAAM,EAAE,MAAM,MAAM,IAAI,qCAAqC,KAAK,SAAS;AAC3E,UAAM,aAAa,4BAA4B,KAAK,SAAS;AAC7D,UAAM,MAAM,8BAA8B,KAAK,WAAW,MAAM,KAAK;AACrE,UAAM,WAAW;AAAA,MACf;AAAA,MACA,EAAE,OAAO,EAAE,QAAQ,OAAO,GAAG,IAAI;AAAA,IACnC;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,cAAc,OAA4E;AAC9F,UAAM,EAAE,MAAM,MAAM,IAAI,qCAAqC,KAAK,SAAS;AAC3E,UAAM,aAAa,4BAA4B,KAAK,SAAS;AAC7D,UAAM,MAAM,8BAA8B,KAAK,WAAW,MAAM,KAAK;AACrE,UAAM,EAAE,OAAO,IAAI,MAAM,WAAW;AAAA,MAClC;AAAA,MACA,EAAE,OAAO,IAAI;AAAA,IACf;AACA,WAAO;AAAA,EACT;AAAA;AAAA,EAGA,MAAM,YAAY,OAAuE;AACvF,UAAM,EAAE,MAAM,MAAM,IAAI,qCAAqC,KAAK,SAAS;AAC3E,UAAM,aAAa,4BAA4B,KAAK,SAAS;AAC7D,UAAM,MAAM,8BAA8B,KAAK,WAAW,MAAM,KAAK;AACrE,UAAM,EAAE,OAAO,IAAI,MAAM,WAAW;AAAA,MAClC;AAAA,MACA,EAAE,OAAO,IAAI;AAAA,IACf;AACA,WAAO;AAAA,EACT;AACF;",
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": "AAAA,SAAS,0BAA0B;AAEnC,SAAS,yBAAyB,qCAAqC;AACvE,SAAS,gBAAgB;AAEzB,SAAS,qBAAqB;AAC9B,SAAS,0BAA0B;AAsEnC,SAAS,8BAA8B,WAA+D;AACpG,MAAI,OAAqC;AACzC,MAAI;AACF,WAAO,UAAU,QAAsC,MAAM;AAAA,EAC/D,QAAQ;AACN,WAAO;AAAA,EACT;AACA,QAAM,WAAW,MAAM,YAAY;AACnC,QAAM,iBAAiB,MAAM,SAAS;AACtC,MAAI,CAAC,YAAY,CAAC,gBAAgB;AAChC,UAAM,IAAI,cAAc,KAAK;AAAA,MAC3B,OAAO;AAAA,IACT,CAAC;AAAA,EACH;AACA,SAAO,EAAE,UAAU,eAAe;AACpC;AAEA,SAAS,oBAAoB,WAAsD;AACjF,QAAM,eAAe,UAAU,QAAuB,IAAI;AAC1D,QAAM,eAAe,mBAAmB,iBAAiB,aAAa,IAAI;AAC1E,QAAM,KAAK,gBAAgB,aAAa,UAAU,MAAM,aAAa,UAAU,IAAI,eAAe;AAClG,SAAO,GAAG,KAAK,EAAE,wBAAwB,MAAM,0BAA0B,QAAQ,gBAAgB,iBAAiB,EAAE,EAAE,CAAC;AACzH;AAOA,SAAS,qCAAqC,WAG5C;AACA,MAAI,OAAqC;AACzC,MAAI;AACF,WAAO,UAAU,QAAsC,MAAM;AAAA,EAC/D,QAAQ;AACN,WAAO;AAAA,EACT;AACA,QAAM,WAAW,MAAM,YAAY;AACnC,QAAM,iBAAiB,MAAM,SAAS;AACtC,MAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,gBAAgB;AACzC,UAAM,IAAI,cAAc,KAAK;AAAA,MAC3B,OAAO;AAAA,IACT,CAAC;AAAA,EACH;AACA,SAAO,EAAE,MAAM,OAAO,EAAE,UAAU,eAAe,EAAE;AACrD;AASA,SAAS,8BACP,WACA,MACA,OACuB;AACvB,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,mBAAmB;AAAA,IACnB,wBAAwB,MAAM;AAAA,IAC9B,iBAAiB,CAAC,MAAM,cAAc;AAAA,EACxC;AACF;AAEA,SAAS,4BAA4B,WAAmD;AACtF,SAAO,UAAU,QAAoB,YAAY;AACnD;AAEA,SAAS,4BAA4B,WAA6D;AAChG,SAAO,UAAU,QAA8B,sBAAsB;AACvE;AAQA,eAAe,8BACb,WACA,YACA,OACgC;AAChC,QAAM,SAAS,oBAAI,IAAsB;AACzC,MAAI,WAAW,WAAW,EAAG,QAAO;AACpC,QAAM,KAAK,oBAAoB,SAAS;AACxC,QAAM,KAAK,GAAG,UAAe;AAC7B,QAAM,YAAY,MAAM,wBAAwB,IAAI,YAAY,KAAK;AACrE,aAAW,CAAC,WAAW,IAAI,KAAK,WAAW;AACzC,UAAM,cAAc,KACjB,OAAO,CAAC,QAAQ,IAAI,WAAW,6BAA6B,CAAC,EAC7D,IAAI,CAAC,QAAQ,IAAI,MAAM,8BAA8B,MAAM,CAAC;AAC/D,WAAO,IAAI,WAAW,WAAW;AAAA,EACnC;AACA,SAAO;AACT;AAUA,SAAS,4BAA4B,YAA4C;AAC/E,SAAO,CAAC;AACV;AAEA,SAAS,mBACP,OACA,sBACwB;AACxB,SAAO,MAAM,IAAI,CAAC,UAAU;AAAA,IAC1B,GAAG;AAAA,IACH,aAAa,qBAAqB,IAAI,KAAK,SAAS,KAAK,CAAC;AAAA,IAC1D,YAAY,4BAA4B,KAAK,SAAS;AAAA,EACxD,EAAE;AACJ;AAEA,eAAe,oBACb,WACA,OACA,OACiC;AACjC,QAAM,aAAa,MAAM,KAAK,IAAI,IAAI,MAAM,IAAI,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC;AAC1E,QAAM,uBAAuB,MAAM,8BAA8B,WAAW,YAAY,KAAK;AAC7F,SAAO,mBAAmB,OAAO,oBAAoB;AACvD;AAQA,eAAe,8BACb,WACA,UACA,OAC6B;AAC7B,MAAI,CAAC,SAAU,QAAO;AACtB,MAAI;AACF,UAAM,KAAK,oBAAoB,SAAS;AACxC,UAAM,MAAM,MAAM,GAAG,QAAQ,UAAU;AAAA,MACrC,MAAM;AAAA,MACN,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM;AAAA,MACtB,WAAW;AAAA,IACb,CAAC;AACD,WAAO,KAAK,iBAAiB;AAAA,EAC/B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,4BAA4B,OAIZ;AACvB,SAAO;AAAA,IACL,QAAQ;AAAA,IACR,WAAW,MAAM;AAAA,IACjB,WAAW,MAAM;AAAA,IACjB,UAAU;AAAA;AAAA;AAAA,IAGV,cAAc;AAAA,IACd,gBAAgB;AAAA,IAChB,oBAAoB;AAAA,IACpB,aAAa,MAAM;AAAA,IACnB,YAAY,4BAA4B,MAAM,SAAS;AAAA,EACzD;AACF;AAEO,MAAM,sBAAmD;AAAA,EAC9D,YAA6B,WAAuC;AAAvC;AAAA,EAAwC;AAAA,EAErE,MAAM,SAAS,OAA+C;AAC5D,UAAM,QAAQ,8BAA8B,KAAK,SAAS;AAC1D,UAAM,wBAAwB,KAAK,UAAU,QAA+B,uBAAuB;AACnG,UAAM,aAAa,MAAM,sBAAsB,oBAAoB,MAAM,UAAU,MAAM,cAAc;AAEvG,UAAM,QAAQ,MAAM,oBAAoB,KAAK,WAAW,MAAM,OAAO,KAAK;AAC1E,UAAM,yBAAyB,MAAM,8BAA8B,KAAK,WAAW,MAAM,UAAU,KAAK;AAQxG,UAAM,kBAAmC;AAAA,MACvC;AAAA,MACA,UAAU,MAAM;AAAA,MAChB,aAAa,MAAM;AAAA,MACnB,OAAO,MAAM;AAAA,MACb,oBAAoB,MAAM;AAAA,MAC1B,YAAY,MAAM;AAAA,MAClB,OAAO,MAAM;AAAA,MACb,GAAI,2BAA2B,SAAY,EAAE,uBAAuB,IAAI,CAAC;AAAA,IAC3E;AAEA,WAAO,mBAAmB,YAAY,eAAe;AAAA,EACvD;AAAA,EAEA,MAAM,mBAAmB,OAKqF;AAC5G,UAAM,QAAQ,8BAA8B,KAAK,SAAS;AAC1D,UAAM,wBAAwB,KAAK,UAAU,QAA+B,uBAAuB;AACnG,UAAM,aAAa,MAAM,sBAAsB,oBAAoB,MAAM,UAAU,MAAM,cAAc;AAEvG,UAAM,uBAAuB,MAAM,8BAA8B,KAAK,WAAW,CAAC,MAAM,SAAS,GAAG,KAAK;AACzG,UAAM,OAAO,4BAA4B;AAAA,MACvC,WAAW,MAAM;AAAA,MACjB,WAAW,MAAM;AAAA,MACjB,aAAa,qBAAqB,IAAI,MAAM,SAAS,KAAK,CAAC;AAAA,IAC7D,CAAC;AASD,UAAM,kBAAmC;AAAA,MACvC,OAAO,CAAC,IAAI;AAAA,MACZ,UAAU;AAAA,MACV,aAAa,MAAM;AAAA,MACnB,OAAO,MAAM;AAAA,MACb,oBAAoB;AAAA,MACpB,YAAY;AAAA,MACZ,OAAO,CAAC;AAAA,IACV;AAEA,UAAM,SAAS,mBAAmB,YAAY,eAAe;AAC7D,WAAO;AAAA,MACL,QAAQ,OAAO,kBAAkB,IAAI,CAAC,aAAa;AAAA,QACjD,aAAa,QAAQ;AAAA,QACrB,eAAe,QAAQ;AAAA;AAAA;AAAA;AAAA,QAIvB,OAAO,QAAQ,QAAQ,CAAC,GAAG,SAAS,CAAC;AAAA,MACvC,EAAE;AAAA,IACJ;AAAA,EACF;AAAA;AAAA;AAAA,EAIA,MAAM,YAAY,OAAyD;AACzE,UAAM,EAAE,MAAM,MAAM,IAAI,qCAAqC,KAAK,SAAS;AAC3E,UAAM,aAAa,4BAA4B,KAAK,SAAS;AAC7D,UAAM,MAAM,8BAA8B,KAAK,WAAW,MAAM,KAAK;AACrE,UAAM,EAAE,OAAO,IAAI,MAAM,WAAW;AAAA,MAClC;AAAA,MACA,EAAE,OAAO,IAAI;AAAA,IACf;AACA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA,EAIA,MAAM,kBAAkB,QAAgB,QAA+B;AACrE,UAAM,EAAE,MAAM,IAAI,qCAAqC,KAAK,SAAS;AACrE,UAAM,KAAK,oBAAoB,KAAK,SAAS;AAC7C,UAAM,4BAA4B,KAAK,SAAS,EAAE,kBAAkB,IAAI,QAAQ,QAAQ,KAAK;AAAA,EAC/F;AAAA;AAAA,EAGA,MAAM,aAAa,OAAwD;AACzE,UAAM,EAAE,MAAM,IAAI,qCAAqC,KAAK,SAAS;AACrE,UAAM,KAAK,oBAAoB,KAAK,SAAS;AAC7C,WAAO,4BAA4B,KAAK,SAAS,EAAE,aAAa,IAAI,OAAO,KAAK;AAAA,EAClF;AAAA;AAAA,EAGA,MAAM,QAAQ,OAA6C;AACzD,UAAM,EAAE,MAAM,MAAM,IAAI,qCAAqC,KAAK,SAAS;AAC3E,UAAM,aAAa,4BAA4B,KAAK,SAAS;AAC7D,UAAM,MAAM,8BAA8B,KAAK,WAAW,MAAM,KAAK;AACrE,UAAM,EAAE,OAAO,IAAI,MAAM,WAAW;AAAA,MAClC;AAAA,MACA,EAAE,OAAO,IAAI;AAAA,IACf;AACA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,YAAY,QAAgB,QAA+B;AAC/D,UAAM,EAAE,MAAM,MAAM,IAAI,qCAAqC,KAAK,SAAS;AAC3E,UAAM,aAAa,4BAA4B,KAAK,SAAS;AAC7D,UAAM,MAAM,8BAA8B,KAAK,WAAW,MAAM,KAAK;AACrE,UAAM,WAAW;AAAA,MACf;AAAA,MACA,EAAE,OAAO,EAAE,QAAQ,OAAO,GAAG,IAAI;AAAA,IACnC;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,cAAc,OAA4E;AAC9F,UAAM,EAAE,MAAM,MAAM,IAAI,qCAAqC,KAAK,SAAS;AAC3E,UAAM,aAAa,4BAA4B,KAAK,SAAS;AAC7D,UAAM,MAAM,8BAA8B,KAAK,WAAW,MAAM,KAAK;AACrE,UAAM,EAAE,OAAO,IAAI,MAAM,WAAW;AAAA,MAClC;AAAA,MACA,EAAE,OAAO,IAAI;AAAA,IACf;AACA,WAAO;AAAA,EACT;AAAA;AAAA,EAGA,MAAM,YAAY,OAAuE;AACvF,UAAM,EAAE,MAAM,MAAM,IAAI,qCAAqC,KAAK,SAAS;AAC3E,UAAM,aAAa,4BAA4B,KAAK,SAAS;AAC7D,UAAM,MAAM,8BAA8B,KAAK,WAAW,MAAM,KAAK;AACrE,UAAM,EAAE,OAAO,IAAI,MAAM,WAAW;AAAA,MAClC;AAAA,MACA,EAAE,OAAO,IAAI;AAAA,IACf;AACA,WAAO;AAAA,EACT;AACF;",
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`,