@endora-commerce/mod-price-lists 0.100.0

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.
Files changed (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -0
  3. package/dist/admin/components/ApplicationRuleBuilder.d.ts +24 -0
  4. package/dist/admin/components/ApplicationRuleBuilder.d.ts.map +1 -0
  5. package/dist/admin/components/ApplicationRuleBuilder.js +249 -0
  6. package/dist/admin/components/ApplicationRuleBuilder.js.map +1 -0
  7. package/dist/admin/components/BracketGrid.d.ts +29 -0
  8. package/dist/admin/components/BracketGrid.d.ts.map +1 -0
  9. package/dist/admin/components/BracketGrid.js +252 -0
  10. package/dist/admin/components/BracketGrid.js.map +1 -0
  11. package/dist/admin/components/DisplayModeOverrideRow.d.ts +46 -0
  12. package/dist/admin/components/DisplayModeOverrideRow.d.ts.map +1 -0
  13. package/dist/admin/components/DisplayModeOverrideRow.js +109 -0
  14. package/dist/admin/components/DisplayModeOverrideRow.js.map +1 -0
  15. package/dist/admin/components/LinkedPriceListsPanel.d.ts +55 -0
  16. package/dist/admin/components/LinkedPriceListsPanel.d.ts.map +1 -0
  17. package/dist/admin/components/LinkedPriceListsPanel.js +131 -0
  18. package/dist/admin/components/LinkedPriceListsPanel.js.map +1 -0
  19. package/dist/admin/index.d.ts +34 -0
  20. package/dist/admin/index.d.ts.map +1 -0
  21. package/dist/admin/index.js +119 -0
  22. package/dist/admin/index.js.map +1 -0
  23. package/dist/admin/pages/DisplayModeOverridesPage.d.ts +25 -0
  24. package/dist/admin/pages/DisplayModeOverridesPage.d.ts.map +1 -0
  25. package/dist/admin/pages/DisplayModeOverridesPage.js +266 -0
  26. package/dist/admin/pages/DisplayModeOverridesPage.js.map +1 -0
  27. package/dist/admin/pages/PriceListDetailPage.d.ts +23 -0
  28. package/dist/admin/pages/PriceListDetailPage.d.ts.map +1 -0
  29. package/dist/admin/pages/PriceListDetailPage.js +421 -0
  30. package/dist/admin/pages/PriceListDetailPage.js.map +1 -0
  31. package/dist/admin/pages/PriceListsPage.d.ts +17 -0
  32. package/dist/admin/pages/PriceListsPage.d.ts.map +1 -0
  33. package/dist/admin/pages/PriceListsPage.js +198 -0
  34. package/dist/admin/pages/PriceListsPage.js.map +1 -0
  35. package/dist/admin/zones/CategoryDisplayMode.d.ts +27 -0
  36. package/dist/admin/zones/CategoryDisplayMode.d.ts.map +1 -0
  37. package/dist/admin/zones/CategoryDisplayMode.js +11 -0
  38. package/dist/admin/zones/CategoryDisplayMode.js.map +1 -0
  39. package/dist/admin/zones/OrganizationDisplayMode.d.ts +36 -0
  40. package/dist/admin/zones/OrganizationDisplayMode.d.ts.map +1 -0
  41. package/dist/admin/zones/OrganizationDisplayMode.js +12 -0
  42. package/dist/admin/zones/OrganizationDisplayMode.js.map +1 -0
  43. package/dist/admin/zones/ProductLinkedPriceLists.d.ts +16 -0
  44. package/dist/admin/zones/ProductLinkedPriceLists.d.ts.map +1 -0
  45. package/dist/admin/zones/ProductLinkedPriceLists.js +7 -0
  46. package/dist/admin/zones/ProductLinkedPriceLists.js.map +1 -0
  47. package/dist/backend/entities/price-display-mode-override.entity.d.ts +23 -0
  48. package/dist/backend/entities/price-display-mode-override.entity.d.ts.map +1 -0
  49. package/dist/backend/entities/price-display-mode-override.entity.js +58 -0
  50. package/dist/backend/entities/price-display-mode-override.entity.js.map +1 -0
  51. package/dist/backend/entities/price-list-price-bracket.entity.d.ts +26 -0
  52. package/dist/backend/entities/price-list-price-bracket.entity.d.ts.map +1 -0
  53. package/dist/backend/entities/price-list-price-bracket.entity.js +73 -0
  54. package/dist/backend/entities/price-list-price-bracket.entity.js.map +1 -0
  55. package/dist/backend/entities/price-list-product.entity.d.ts +15 -0
  56. package/dist/backend/entities/price-list-product.entity.d.ts.map +1 -0
  57. package/dist/backend/entities/price-list-product.entity.js +43 -0
  58. package/dist/backend/entities/price-list-product.entity.js.map +1 -0
  59. package/dist/backend/entities/price-list.entity.d.ts +34 -0
  60. package/dist/backend/entities/price-list.entity.d.ts.map +1 -0
  61. package/dist/backend/entities/price-list.entity.js +115 -0
  62. package/dist/backend/entities/price-list.entity.js.map +1 -0
  63. package/dist/backend/index.d.ts +125 -0
  64. package/dist/backend/index.d.ts.map +1 -0
  65. package/dist/backend/index.js +179 -0
  66. package/dist/backend/index.js.map +1 -0
  67. package/dist/backend/plugin.d.ts +68 -0
  68. package/dist/backend/plugin.d.ts.map +1 -0
  69. package/dist/backend/plugin.js +88 -0
  70. package/dist/backend/plugin.js.map +1 -0
  71. package/dist/backend/routes.d.ts +40 -0
  72. package/dist/backend/routes.d.ts.map +1 -0
  73. package/dist/backend/routes.js +447 -0
  74. package/dist/backend/routes.js.map +1 -0
  75. package/dist/backend/routes.storefront.d.ts +47 -0
  76. package/dist/backend/routes.storefront.d.ts.map +1 -0
  77. package/dist/backend/routes.storefront.js +131 -0
  78. package/dist/backend/routes.storefront.js.map +1 -0
  79. package/dist/backend/services/application-rule-evaluator.d.ts +25 -0
  80. package/dist/backend/services/application-rule-evaluator.d.ts.map +1 -0
  81. package/dist/backend/services/application-rule-evaluator.js +2 -0
  82. package/dist/backend/services/application-rule-evaluator.js.map +1 -0
  83. package/dist/backend/services/audit-references.d.ts +11 -0
  84. package/dist/backend/services/audit-references.d.ts.map +1 -0
  85. package/dist/backend/services/audit-references.js +23 -0
  86. package/dist/backend/services/audit-references.js.map +1 -0
  87. package/dist/backend/services/default-price-list-migration.d.ts +70 -0
  88. package/dist/backend/services/default-price-list-migration.d.ts.map +1 -0
  89. package/dist/backend/services/default-price-list-migration.js +177 -0
  90. package/dist/backend/services/default-price-list-migration.js.map +1 -0
  91. package/dist/backend/services/display-mode-resolver.d.ts +78 -0
  92. package/dist/backend/services/display-mode-resolver.d.ts.map +1 -0
  93. package/dist/backend/services/display-mode-resolver.js +71 -0
  94. package/dist/backend/services/display-mode-resolver.js.map +1 -0
  95. package/dist/backend/services/listing-price-chain.d.ts +26 -0
  96. package/dist/backend/services/listing-price-chain.d.ts.map +1 -0
  97. package/dist/backend/services/listing-price-chain.js +55 -0
  98. package/dist/backend/services/listing-price-chain.js.map +1 -0
  99. package/dist/backend/services/price-bracket-resolver.d.ts +24 -0
  100. package/dist/backend/services/price-bracket-resolver.d.ts.map +1 -0
  101. package/dist/backend/services/price-bracket-resolver.js +16 -0
  102. package/dist/backend/services/price-bracket-resolver.js.map +1 -0
  103. package/dist/backend/services/price-list-candidate-vector.d.ts +116 -0
  104. package/dist/backend/services/price-list-candidate-vector.d.ts.map +1 -0
  105. package/dist/backend/services/price-list-candidate-vector.js +133 -0
  106. package/dist/backend/services/price-list-candidate-vector.js.map +1 -0
  107. package/dist/backend/services/price-list-currency-reference.d.ts +12 -0
  108. package/dist/backend/services/price-list-currency-reference.d.ts.map +1 -0
  109. package/dist/backend/services/price-list-currency-reference.js +24 -0
  110. package/dist/backend/services/price-list-currency-reference.js.map +1 -0
  111. package/dist/backend/services/price-list-read-port.d.ts +29 -0
  112. package/dist/backend/services/price-list-read-port.d.ts.map +1 -0
  113. package/dist/backend/services/price-list-read-port.js +64 -0
  114. package/dist/backend/services/price-list-read-port.js.map +1 -0
  115. package/dist/backend/services/price-list-resolver.d.ts +26 -0
  116. package/dist/backend/services/price-list-resolver.d.ts.map +1 -0
  117. package/dist/backend/services/price-list-resolver.js +37 -0
  118. package/dist/backend/services/price-list-resolver.js.map +1 -0
  119. package/dist/backend/services/price-list-service.d.ts +431 -0
  120. package/dist/backend/services/price-list-service.d.ts.map +1 -0
  121. package/dist/backend/services/price-list-service.js +1238 -0
  122. package/dist/backend/services/price-list-service.js.map +1 -0
  123. package/dist/backend/services/price-list-status-worker.d.ts +28 -0
  124. package/dist/backend/services/price-list-status-worker.d.ts.map +1 -0
  125. package/dist/backend/services/price-list-status-worker.js +38 -0
  126. package/dist/backend/services/price-list-status-worker.js.map +1 -0
  127. package/dist/backend/services/pricing-cache.d.ts +53 -0
  128. package/dist/backend/services/pricing-cache.d.ts.map +1 -0
  129. package/dist/backend/services/pricing-cache.js +88 -0
  130. package/dist/backend/services/pricing-cache.js.map +1 -0
  131. package/dist/backend/services/pricing-service.d.ts +304 -0
  132. package/dist/backend/services/pricing-service.d.ts.map +1 -0
  133. package/dist/backend/services/pricing-service.interface.d.ts +151 -0
  134. package/dist/backend/services/pricing-service.interface.d.ts.map +1 -0
  135. package/dist/backend/services/pricing-service.interface.js +10 -0
  136. package/dist/backend/services/pricing-service.interface.js.map +1 -0
  137. package/dist/backend/services/pricing-service.js +803 -0
  138. package/dist/backend/services/pricing-service.js.map +1 -0
  139. package/dist/backend/services/unit-price-ordering.d.ts +121 -0
  140. package/dist/backend/services/unit-price-ordering.d.ts.map +1 -0
  141. package/dist/backend/services/unit-price-ordering.js +164 -0
  142. package/dist/backend/services/unit-price-ordering.js.map +1 -0
  143. package/dist/manifest.d.ts +218 -0
  144. package/dist/manifest.d.ts.map +1 -0
  145. package/dist/manifest.js +228 -0
  146. package/dist/manifest.js.map +1 -0
  147. package/dist/migrations/20260426T075235_price_lists_pricing_init.d.ts +24 -0
  148. package/dist/migrations/20260426T075235_price_lists_pricing_init.d.ts.map +1 -0
  149. package/dist/migrations/20260426T075235_price_lists_pricing_init.js +89 -0
  150. package/dist/migrations/20260426T075235_price_lists_pricing_init.js.map +1 -0
  151. package/dist/migrations/20260504T125655_price_lists_engine.d.ts +6 -0
  152. package/dist/migrations/20260504T125655_price_lists_engine.d.ts.map +1 -0
  153. package/dist/migrations/20260504T125655_price_lists_engine.js +319 -0
  154. package/dist/migrations/20260504T125655_price_lists_engine.js.map +1 -0
  155. package/dist/migrations/20260817T055457_price_lists_single_system_price_list.d.ts +15 -0
  156. package/dist/migrations/20260817T055457_price_lists_single_system_price_list.d.ts.map +1 -0
  157. package/dist/migrations/20260817T055457_price_lists_single_system_price_list.js +58 -0
  158. package/dist/migrations/20260817T055457_price_lists_single_system_price_list.js.map +1 -0
  159. package/dist/migrations/20260821T135907_price_lists_unit_price_amount_index.d.ts +40 -0
  160. package/dist/migrations/20260821T135907_price_lists_unit_price_amount_index.d.ts.map +1 -0
  161. package/dist/migrations/20260821T135907_price_lists_unit_price_amount_index.js +46 -0
  162. package/dist/migrations/20260821T135907_price_lists_unit_price_amount_index.js.map +1 -0
  163. package/dist/migrations/index.d.ts +30 -0
  164. package/dist/migrations/index.d.ts.map +1 -0
  165. package/dist/migrations/index.js +35 -0
  166. package/dist/migrations/index.js.map +1 -0
  167. package/docs/price_lists.md +278 -0
  168. package/i18n/en.json +14 -0
  169. package/i18n/pl.json +14 -0
  170. package/package.json +99 -0
  171. package/tailwind.css +14 -0
@@ -0,0 +1,1238 @@
1
+ import { ERROR_CODES } from '@endora-commerce/contracts';
2
+ import { HttpError } from '@endora-commerce/platform/http';
3
+ import { PriceList } from '../entities/price-list.entity.js';
4
+ import { PriceListProduct } from '../entities/price-list-product.entity.js';
5
+ import { PriceListPriceBracket } from '../entities/price-list-price-bracket.entity.js';
6
+ import { PriceDisplayModeOverride } from '../entities/price-display-mode-override.entity.js';
7
+ import { SalesChannel } from '@endora-commerce/platform/kernel';
8
+ import { Setting } from '@endora-commerce/platform/kernel';
9
+ import { SettingValue } from '@endora-commerce/platform/kernel';
10
+ import { decideDisplayMode, settingsDisplayModeKey, } from './display-mode-resolver.js';
11
+ import { randomUUID } from 'crypto';
12
+ /**
13
+ * PriceListService — admin CRUD over PriceList + the two child collections
14
+ * (items + assignments). Keeping all three operations on the same service
15
+ * keeps the "edit a price list and its rules" admin flow on one transaction
16
+ * boundary when we need it later.
17
+ */
18
+ export class PriceListService {
19
+ emFactory;
20
+ pricingCache;
21
+ auditLog;
22
+ commandBus;
23
+ targetReads;
24
+ constructor(emFactory,
25
+ /**
26
+ * Optional pricing cache. Every write path on this service calls
27
+ * `invalidateAll()` so the next read repopulates from the DB.
28
+ * Coarse but correct — fan-out makes per-tuple invalidation
29
+ * unprofitable until profiling shows otherwise.
30
+ */
31
+ pricingCache,
32
+ /** Feature 024 — optional audit log writer. When omitted, no audit
33
+ * rows are emitted (tests that don't care about audit pass nothing). */
34
+ auditLog,
35
+ /**
36
+ * Feature 054 — when injected, `patch` runs through the Command Bus so the
37
+ * update is audited co-transactionally (Principle XIII). Optional: bus-less
38
+ * construction keeps the legacy audit path, byte-identical.
39
+ */
40
+ commandBus,
41
+ /**
42
+ * Feature 075 Phase C — the ports behind every rule-target and
43
+ * override-target existence check. Optional in the signature because a
44
+ * price list with no organisation, category or product target never reaches
45
+ * them; **never optional in effect**, because the paths that need them
46
+ * refuse rather than skip when they are absent (see {@link targets}).
47
+ */
48
+ targetReads) {
49
+ this.emFactory = emFactory;
50
+ this.pricingCache = pricingCache;
51
+ this.auditLog = auditLog;
52
+ this.commandBus = commandBus;
53
+ this.targetReads = targetReads;
54
+ }
55
+ /**
56
+ * The neighbour ports, or a refusal. Skipping the validation when nothing was
57
+ * wired would turn "this composition cannot reach `catalog`" into "every
58
+ * category id is valid", which is the fail-open shape feature 075 exists to
59
+ * remove.
60
+ */
61
+ targets() {
62
+ if (!this.targetReads) {
63
+ throw new HttpError(500, ERROR_CODES.INTERNAL, 'This price-list operation validates a target owned by `catalog` or `organizations`, ' +
64
+ 'and this composition constructed PriceListService without their read ports.');
65
+ }
66
+ return this.targetReads;
67
+ }
68
+ invalidatePricingCache() {
69
+ this.pricingCache?.invalidateAll();
70
+ }
71
+ async audit(action, row, stateBefore, stateAfter, auditCtx) {
72
+ if (!this.auditLog || !auditCtx)
73
+ return;
74
+ await this.auditLog.record({
75
+ actorAdminUserId: auditCtx.actorAdminUserId,
76
+ ...(auditCtx.impersonatedCustomerAccountId !== undefined
77
+ ? { impersonatedCustomerAccountId: auditCtx.impersonatedCustomerAccountId }
78
+ : {}),
79
+ action,
80
+ objectType: 'price_list',
81
+ objectId: row.id,
82
+ ...(stateBefore !== null ? { stateBefore } : {}),
83
+ ...(stateAfter !== null ? { stateAfter } : {}),
84
+ ...(auditCtx.ipAddress !== undefined ? { ipAddress: auditCtx.ipAddress } : {}),
85
+ ...(auditCtx.userAgent !== undefined ? { userAgent: auditCtx.userAgent } : {}),
86
+ ...(auditCtx.requestId !== undefined ? { requestId: auditCtx.requestId } : {}),
87
+ });
88
+ }
89
+ /**
90
+ * Feature 054 — run a price-list write through the Command Bus so the audit is
91
+ * co-transactional (Principle XIII), or fall back to a self-forked em + the
92
+ * legacy `this.audit()` helper when no bus is wired (bus-less test
93
+ * constructions). `write` performs the mutation on the given em (no flush) and
94
+ * returns the caller result, the audit row identity, and the before/after
95
+ * snapshot; a `skipAudit` result commits with no audit row and no cache flush.
96
+ */
97
+ async #runAudited(action, objectId, auditCtx, write) {
98
+ if (this.commandBus) {
99
+ return this.commandBus.run({
100
+ action,
101
+ objectType: 'price_list',
102
+ objectId,
103
+ run: async ({ em }) => {
104
+ const w = await write(em);
105
+ if (w.skipAudit)
106
+ return { result: w.result, skipAudit: true };
107
+ this.invalidatePricingCache();
108
+ return { result: w.result, before: w.before, after: w.after };
109
+ },
110
+ });
111
+ }
112
+ const em = this.emFactory();
113
+ const w = await write(em);
114
+ await em.flush();
115
+ if (!w.skipAudit) {
116
+ this.invalidatePricingCache();
117
+ await this.audit(action, w.row, w.before, w.after, auditCtx);
118
+ }
119
+ return w.result;
120
+ }
121
+ // ---- PriceList -----------------------------------------------------
122
+ async listEngine(filter = {}) {
123
+ const where = {};
124
+ if (filter.status && filter.status.length > 0)
125
+ where['status'] = { $in: filter.status };
126
+ if (filter.type && filter.type.length > 0)
127
+ where['type'] = { $in: filter.type };
128
+ if (filter.search && filter.search.trim().length > 0) {
129
+ where['name'] = { $ilike: `%${filter.search.trim()}%` };
130
+ }
131
+ return this.emFactory().find(PriceList, where, {
132
+ orderBy: { isSystem: 'desc', modifiedAt: 'desc', name: 'asc' },
133
+ });
134
+ }
135
+ async getById(id, em) {
136
+ const ent = em ?? this.emFactory();
137
+ const row = await ent.findOne(PriceList, { id });
138
+ if (!row)
139
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, `Price list ${id} not found.`);
140
+ return row;
141
+ }
142
+ async remove(id) {
143
+ await this.#runAudited('price_list.delete', id, undefined, async (em) => {
144
+ const row = await em.findOne(PriceList, { id });
145
+ if (!row) {
146
+ return { result: undefined, row: { id, name: '' }, before: null, after: null, skipAudit: true };
147
+ }
148
+ if (row.isSystem) {
149
+ throw new HttpError(403, ERROR_CODES.FORBIDDEN, 'The Default price list cannot be deleted; it is the system fallback.');
150
+ }
151
+ const before = { name: row.name, code: row.code, type: row.type, status: row.status };
152
+ em.remove(row);
153
+ return { result: undefined, row, before, after: null };
154
+ });
155
+ }
156
+ /**
157
+ * Default-list rule-attachment guard (FR-006). Rejects any attempt to attach
158
+ * a non-empty Application Rule to the seeded `Default` row.
159
+ */
160
+ async assertCanSetApplicationRule(id, rule) {
161
+ const row = await this.getById(id);
162
+ if (row.isSystem && rule.kind !== 'all') {
163
+ throw new HttpError(403, ERROR_CODES.FORBIDDEN, 'The Default price list cannot carry an Application Rule; it always matches as the global fallback.');
164
+ }
165
+ }
166
+ /**
167
+ * Default-list status-change guard (FR-005 / FR-006 / spec.md §State Machines).
168
+ * The system Default row stays `active` for the platform's lifetime. The
169
+ * service rejects any attempt to move it to `draft`, `scheduled`, or `expired`.
170
+ */
171
+ async assertCanTransitionStatus(id, nextStatus) {
172
+ const row = await this.getById(id);
173
+ if (row.isSystem && nextStatus !== 'active') {
174
+ throw new HttpError(403, ERROR_CODES.FORBIDDEN, 'The Default price list must remain active.');
175
+ }
176
+ }
177
+ // ---- Engine CRUD + lifecycle (feature 011) -------------------------
178
+ /**
179
+ * Create a new price list. Status defaults to `draft`. Default-list
180
+ * protections are out of scope here — system rows are seeded by migration
181
+ * 031, never created via this method.
182
+ */
183
+ async create(input, auditCtx) {
184
+ this.assertDateSanity(input.startsAt ?? null, input.endsAt ?? null, true);
185
+ const normalisedRule = input.applicationRule !== undefined
186
+ ? await this.normaliseAndValidateRule(input.applicationRule)
187
+ : { kind: 'all' };
188
+ const id = randomUUID();
189
+ return this.#runAudited('price_list.create', id, auditCtx, async (em) => {
190
+ const row = em.create(PriceList, {
191
+ id,
192
+ // Legacy columns are required by the foundation schema; populate them
193
+ // with engine-equivalent values so writes don't fail until contract
194
+ // migration retires them.
195
+ code: `pl-${randomUUID()}`,
196
+ name: input.name,
197
+ currency: 'PLN',
198
+ isDefault: false,
199
+ priority: 0,
200
+ type: input.type,
201
+ status: 'draft',
202
+ startsAt: input.startsAt ?? null,
203
+ endsAt: input.endsAt ?? null,
204
+ applicationRule: normalisedRule,
205
+ isSystem: false,
206
+ modifiedAt: new Date(),
207
+ });
208
+ return {
209
+ result: row,
210
+ row,
211
+ before: null,
212
+ after: {
213
+ name: row.name,
214
+ code: row.code,
215
+ type: row.type,
216
+ status: row.status,
217
+ startsAt: row.startsAt ?? null,
218
+ endsAt: row.endsAt ?? null,
219
+ applicationRuleKind: row.applicationRule.kind,
220
+ },
221
+ };
222
+ });
223
+ }
224
+ /**
225
+ * Partial update. Bumps `modifiedAt` on every material change.
226
+ * Refuses non-empty rule attachment on the seeded `Default` row (FR-006).
227
+ */
228
+ async patch(id, input, auditCtx) {
229
+ // Feature 054 — audited path: the Command Bus records the update
230
+ // co-transactionally. No-op patches (nothing changed) skip the audit row.
231
+ if (this.commandBus) {
232
+ return this.commandBus.run(this.#patchCommand(id, input));
233
+ }
234
+ // Legacy fallback (bus-less construction): unaudited unless auditCtx given.
235
+ const em = this.emFactory();
236
+ const r = await this.#applyPatch(em, id, input);
237
+ await em.flush();
238
+ if (r.mutated) {
239
+ this.invalidatePricingCache();
240
+ await this.audit('price_list.update', r.row, r.stateBefore, r.stateAfter, auditCtx);
241
+ }
242
+ return r.row;
243
+ }
244
+ /** The `patch` write expressed as a Command (audited via the bus). */
245
+ #patchCommand(id, input) {
246
+ return {
247
+ action: 'price_list.update',
248
+ objectType: 'price_list',
249
+ objectId: id,
250
+ run: async ({ em }) => {
251
+ const r = await this.#applyPatch(em, id, input);
252
+ if (!r.mutated) {
253
+ // Nothing changed → commit without an audit row.
254
+ return { result: r.row, skipAudit: true };
255
+ }
256
+ this.invalidatePricingCache();
257
+ return { result: r.row, before: r.stateBefore, after: r.stateAfter };
258
+ },
259
+ };
260
+ }
261
+ /**
262
+ * Pure patch write on the given em — no flush, no audit, no cache invalidation.
263
+ * Returns the row, whether it mutated, and the before/after snapshots.
264
+ */
265
+ async #applyPatch(em, id, input) {
266
+ const row = await this.getById(id, em);
267
+ let normalisedRule;
268
+ if (input.applicationRule !== undefined) {
269
+ await this.assertCanSetApplicationRule(id, input.applicationRule);
270
+ normalisedRule = await this.normaliseAndValidateRule(input.applicationRule);
271
+ }
272
+ const nextStartsAt = input.startsAt !== undefined ? input.startsAt : row.startsAt ?? null;
273
+ const nextEndsAt = input.endsAt !== undefined ? input.endsAt : row.endsAt ?? null;
274
+ if (input.startsAt !== undefined || input.endsAt !== undefined) {
275
+ this.assertDateSanity(nextStartsAt, nextEndsAt, false);
276
+ }
277
+ const stateBefore = {
278
+ name: row.name,
279
+ type: row.type,
280
+ status: row.status,
281
+ startsAt: row.startsAt ?? null,
282
+ endsAt: row.endsAt ?? null,
283
+ };
284
+ let mutated = false;
285
+ const changedFields = [];
286
+ if (input.name !== undefined && input.name !== row.name) {
287
+ row.name = input.name;
288
+ mutated = true;
289
+ changedFields.push('name');
290
+ }
291
+ if (input.type !== undefined && input.type !== row.type) {
292
+ row.type = input.type;
293
+ mutated = true;
294
+ changedFields.push('type');
295
+ }
296
+ if (input.startsAt !== undefined) {
297
+ row.startsAt = input.startsAt;
298
+ mutated = true;
299
+ changedFields.push('startsAt');
300
+ }
301
+ if (input.endsAt !== undefined) {
302
+ row.endsAt = input.endsAt;
303
+ mutated = true;
304
+ changedFields.push('endsAt');
305
+ }
306
+ if (input.applicationRule !== undefined && normalisedRule !== undefined) {
307
+ row.applicationRule = normalisedRule;
308
+ mutated = true;
309
+ changedFields.push('applicationRule');
310
+ }
311
+ if (mutated) {
312
+ row.modifiedAt = new Date();
313
+ }
314
+ const stateAfter = {
315
+ name: row.name,
316
+ type: row.type,
317
+ status: row.status,
318
+ startsAt: row.startsAt ?? null,
319
+ endsAt: row.endsAt ?? null,
320
+ changedFields,
321
+ };
322
+ return { row, mutated, stateBefore, stateAfter };
323
+ }
324
+ /**
325
+ * Manual transition: draft → active (or scheduled / expired if dates
326
+ * dictate). FR-009 row 1.
327
+ */
328
+ async activate(id, auditCtx) {
329
+ // Pre-read (no mutation) to resolve the dynamic action token before running
330
+ // the audited write: `price_list.activate` for a live state, or
331
+ // `price_list.expire` when the start/end dates push it straight to expired.
332
+ const preRow = await this.getById(id);
333
+ if (!preRow.isSystem && preRow.applicationRule.kind === 'all') {
334
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, 'A non-Default price list cannot be activated with an empty Application Rule.');
335
+ }
336
+ const now = new Date();
337
+ const next = preRow.startsAt && preRow.startsAt > now
338
+ ? 'scheduled'
339
+ : preRow.endsAt && preRow.endsAt < now
340
+ ? 'expired'
341
+ : 'active';
342
+ const action = next === 'expired' ? 'price_list.expire' : 'price_list.activate';
343
+ return this.#runAudited(action, id, auditCtx, async (em) => {
344
+ const row = await this.getById(id, em);
345
+ if (row.status === next)
346
+ return { result: row, row, before: null, after: null, skipAudit: true };
347
+ await this.assertCanTransitionStatus(id, next);
348
+ const previousStatus = row.status;
349
+ row.status = next;
350
+ row.modifiedAt = new Date();
351
+ return {
352
+ result: row,
353
+ row,
354
+ before: { status: previousStatus },
355
+ after: { name: row.name, status: row.status, activatedAt: new Date() },
356
+ };
357
+ });
358
+ }
359
+ /**
360
+ * Manual transition: any state → draft. Freezes the list immediately.
361
+ */
362
+ async draftify(id, auditCtx) {
363
+ return this.#runAudited('price_list.draftify', id, auditCtx, async (em) => {
364
+ const row = await this.getById(id, em);
365
+ if (row.status === 'draft')
366
+ return { result: row, row, before: null, after: null, skipAudit: true };
367
+ await this.assertCanTransitionStatus(id, 'draft');
368
+ const previousStatus = row.status;
369
+ row.status = 'draft';
370
+ row.modifiedAt = new Date();
371
+ return {
372
+ result: row,
373
+ row,
374
+ before: { status: previousStatus },
375
+ after: { name: row.name, status: row.status },
376
+ };
377
+ });
378
+ }
379
+ /**
380
+ * Duplicate a price list. Copies the rule, the assigned products, and
381
+ * every bracket row. Resets status to `draft`, clears dates, and derives
382
+ * a unique name (suffix ` (copy)`, ` (copy 2)`, …) — FR-013.
383
+ */
384
+ async duplicate(id, auditCtx) {
385
+ const dupId = randomUUID();
386
+ return this.#runAudited('price_list.duplicate', dupId, auditCtx, async (em) => {
387
+ const source = await this.getById(id, em);
388
+ const baseName = source.name;
389
+ const candidates = await em.find(PriceList, { name: { $like: `${baseName} (copy%` } }, { fields: ['id', 'name'] });
390
+ let suffix = ' (copy)';
391
+ if (candidates.length > 0) {
392
+ // Find the next available numeric suffix.
393
+ let n = 2;
394
+ while (candidates.some((c) => c.name === `${baseName} (copy ${n})`)) {
395
+ n += 1;
396
+ }
397
+ // If the bare " (copy)" doesn't exist yet, use it.
398
+ if (!candidates.some((c) => c.name === `${baseName} (copy)`)) {
399
+ suffix = ' (copy)';
400
+ }
401
+ else {
402
+ suffix = ` (copy ${n})`;
403
+ }
404
+ }
405
+ const newName = `${baseName}${suffix}`;
406
+ const dup = em.create(PriceList, {
407
+ id: dupId,
408
+ code: `pl-${randomUUID()}`,
409
+ name: newName,
410
+ currency: source.currency,
411
+ isDefault: false,
412
+ priority: 0,
413
+ type: source.type,
414
+ status: 'draft',
415
+ startsAt: null,
416
+ endsAt: null,
417
+ applicationRule: structuredClone(source.applicationRule),
418
+ isSystem: false,
419
+ modifiedAt: new Date(),
420
+ });
421
+ // `priceListId` is a plain column (not a mapped relation), so the UoW does
422
+ // not order the parent insert first — flush the new list before its
423
+ // children, then assignments before brackets (composite-FK ordering).
424
+ await em.flush();
425
+ // Copy assignments first (FK target), then brackets.
426
+ const products = await em.find(PriceListProduct, { priceListId: source.id });
427
+ for (const p of products) {
428
+ em.create(PriceListProduct, { priceListId: dup.id, productId: p.productId });
429
+ }
430
+ await em.flush();
431
+ const brackets = await em.find(PriceListPriceBracket, { priceListId: source.id });
432
+ for (const b of brackets) {
433
+ em.create(PriceListPriceBracket, {
434
+ priceListId: dup.id,
435
+ productId: b.productId,
436
+ currencyCode: b.currencyCode,
437
+ minQuantity: b.minQuantity,
438
+ maxQuantity: b.maxQuantity ?? null,
439
+ amount: b.amount,
440
+ });
441
+ }
442
+ return {
443
+ result: dup,
444
+ row: dup,
445
+ before: null,
446
+ after: {
447
+ name: dup.name,
448
+ sourcePriceListId: source.id,
449
+ sourceName: source.name,
450
+ },
451
+ };
452
+ });
453
+ }
454
+ /**
455
+ * Normalise + validate an Application Rule (US4 / FR-020..FR-024).
456
+ *
457
+ * Steps in order:
458
+ * 1. Recursively walk the AST.
459
+ * 2. For criteria: dedupe values, uppercase currency codes, validate
460
+ * target IDs against their respective tables. Empty `values` arrays
461
+ * collapse to `{ kind: 'all' }`.
462
+ * 3. For groups: recursively normalise each child, then drop children
463
+ * that collapsed to `{ kind: 'all' }`. If the group becomes empty
464
+ * it collapses to `{ kind: 'all' }` as well; if it's left with a
465
+ * single child, that child takes its place.
466
+ * 4. Reject malformed currencies, depth > 5, and unknown target IDs
467
+ * with `400 VALIDATION_FAILED`.
468
+ *
469
+ * Returns the normalised rule. Does NOT enforce FR-023 (non-Default
470
+ * rules must be non-empty); that gate fires at activation time.
471
+ */
472
+ async normaliseAndValidateRule(rule) {
473
+ return this.normaliseRuleNode(rule, 0);
474
+ }
475
+ async normaliseRuleNode(node, depth) {
476
+ if (depth > 5) {
477
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, 'Application rule exceeds the maximum nesting depth of 5.');
478
+ }
479
+ if (node.kind === 'all')
480
+ return node;
481
+ if (node.kind === 'criterion') {
482
+ // Currency: uppercase, validate format.
483
+ if (node.type === 'currency') {
484
+ const upper = node.values.map((v) => v.toUpperCase());
485
+ for (const v of upper) {
486
+ if (!/^[A-Z]{3}$/.test(v)) {
487
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `Invalid currency code in rule: ${v}`);
488
+ }
489
+ }
490
+ const dedup = Array.from(new Set(upper));
491
+ if (dedup.length === 0)
492
+ return { kind: 'all' };
493
+ return { kind: 'criterion', type: 'currency', values: dedup };
494
+ }
495
+ // ID-based criterion types.
496
+ const dedup = Array.from(new Set(node.values));
497
+ if (dedup.length === 0)
498
+ return { kind: 'all' };
499
+ await this.assertTargetsExist(node.type, dedup);
500
+ return { kind: 'criterion', type: node.type, values: dedup };
501
+ }
502
+ // Group node.
503
+ const childResults = [];
504
+ for (const child of node.children) {
505
+ const normalised = await this.normaliseRuleNode(child, depth + 1);
506
+ childResults.push(normalised);
507
+ }
508
+ // Drop "all" children — they don't constrain the group.
509
+ const meaningful = childResults.filter((c) => c.kind !== 'all');
510
+ if (meaningful.length === 0) {
511
+ return { kind: 'all' };
512
+ }
513
+ if (meaningful.length === 1) {
514
+ return meaningful[0];
515
+ }
516
+ return { kind: 'group', op: node.op, children: meaningful };
517
+ }
518
+ /**
519
+ * Validate that every value in an ID-based criterion is a real row in the
520
+ * appropriate table. Throws `400 VALIDATION_FAILED` for any unknown ID.
521
+ */
522
+ async assertTargetsExist(type, ids) {
523
+ const em = this.emFactory();
524
+ let found;
525
+ switch (type) {
526
+ case 'salesChannel':
527
+ found = await em.count(SalesChannel, { id: { $in: ids } });
528
+ break;
529
+ case 'customerGroup':
530
+ found = (await this.targets().customerGroupRead.findByIds(ids)).length;
531
+ break;
532
+ case 'organization':
533
+ found = await this.targets().organizationDetails.countByIds(ids);
534
+ break;
535
+ case 'category':
536
+ found = await this.targets().catalogCategoryRead.countByIds(ids);
537
+ break;
538
+ }
539
+ if (found !== ids.length) {
540
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `One or more ${type} IDs in the rule do not exist.`);
541
+ }
542
+ }
543
+ /**
544
+ * Date sanity per FR-010: `endsAt` must be greater than `startsAt`; at
545
+ * creation time `endsAt` must be in the future.
546
+ */
547
+ assertDateSanity(startsAt, endsAt, onCreate) {
548
+ if (endsAt && startsAt && endsAt <= startsAt) {
549
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, 'endsAt must be greater than startsAt.');
550
+ }
551
+ if (onCreate && endsAt && endsAt < new Date()) {
552
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, 'endsAt must be in the future at creation time.');
553
+ }
554
+ }
555
+ // ---- Engine: linked price-lists panel (US8) ------------------------
556
+ /**
557
+ * For a given product, return every price list the product is assigned
558
+ * to with a per-currency bracket summary and a deep-link path. Powers
559
+ * the admin Catalog product editor's Pricing tab (FR-044/045/046).
560
+ */
561
+ async summarizeBracketsForProduct(productId) {
562
+ const em = this.emFactory();
563
+ const assignments = await em.find(PriceListProduct, { productId });
564
+ if (assignments.length === 0)
565
+ return [];
566
+ const listIds = assignments.map((a) => a.priceListId);
567
+ const lists = await em.find(PriceList, { id: { $in: listIds } }, { orderBy: { isSystem: 'desc', name: 'asc' } });
568
+ const brackets = await em.find(PriceListPriceBracket, { priceListId: { $in: listIds }, productId }, { orderBy: { currencyCode: 'asc', minQuantity: 'asc' } });
569
+ return lists.map((list) => {
570
+ const listBrackets = brackets.filter((b) => b.priceListId === list.id);
571
+ const byCurrency = listBrackets.reduce((acc, b) => {
572
+ (acc[b.currencyCode] ??= []).push(b);
573
+ return acc;
574
+ }, {});
575
+ const summary = Object.entries(byCurrency)
576
+ .sort(([a], [b]) => a.localeCompare(b))
577
+ .map(([currencyCode, rows]) => ({
578
+ currencyCode,
579
+ summary: this.summariseBrackets(rows),
580
+ }));
581
+ return {
582
+ list: {
583
+ id: list.id,
584
+ name: list.name,
585
+ type: list.type,
586
+ status: list.status,
587
+ modifiedAt: list.modifiedAt,
588
+ },
589
+ summary,
590
+ deepLinkPath: `/admin/price-lists/${list.id}/products?focus=${productId}`,
591
+ };
592
+ });
593
+ }
594
+ /**
595
+ * Build a human-readable summary string for one currency's bracket
596
+ * series, e.g.:
597
+ * - "80,0000 across 1 bracket"
598
+ * - "80,0000 – 100,0000 across 3 brackets"
599
+ * The numeric formatting deliberately keeps the storage scale (4
600
+ * fractional digits); the admin UI may re-format per locale.
601
+ */
602
+ summariseBrackets(rows) {
603
+ if (rows.length === 0)
604
+ return 'no brackets';
605
+ const amounts = rows.map((r) => Number(r.amount));
606
+ const min = Math.min(...amounts);
607
+ const max = Math.max(...amounts);
608
+ const count = rows.length;
609
+ const noun = count === 1 ? 'bracket' : 'brackets';
610
+ if (min === max) {
611
+ return `${this.formatAmount(min)} across ${count} ${noun}`;
612
+ }
613
+ return `${this.formatAmount(min)} – ${this.formatAmount(max)} across ${count} ${noun}`;
614
+ }
615
+ formatAmount(value) {
616
+ return value.toFixed(4);
617
+ }
618
+ // ---- Engine: display mode (US7) ------------------------------------
619
+ /**
620
+ * Upsert a per-Org / per-Category / per-Product display-mode override
621
+ * (FR-038). The mode value `'inherit'` deletes the override row.
622
+ * Validates the target ID against the appropriate table — orphans are
623
+ * refused with 400 (the polymorphic FK is enforced here, not at the DB
624
+ * level, since the target table varies — see data-model.md §1.4).
625
+ */
626
+ async upsertDisplayModeOverride(scope, targetId, mode) {
627
+ // command-coverage-ignore: a pricing-DISPLAY config toggle
628
+ // (PriceDisplayModeOverride) — a presentation setting for how prices render,
629
+ // not a price/catalog value mutation, so it is not an admin-audit target.
630
+ const em = this.emFactory();
631
+ await this.assertOverrideTargetExists(scope, targetId);
632
+ const existing = await em.findOne(PriceDisplayModeOverride, { scope, targetId });
633
+ if (mode === 'inherit') {
634
+ if (existing) {
635
+ await em.removeAndFlush(existing);
636
+ this.invalidatePricingCache();
637
+ }
638
+ return null;
639
+ }
640
+ if (existing) {
641
+ existing.mode = mode;
642
+ existing.updatedAt = new Date();
643
+ await em.flush();
644
+ this.invalidatePricingCache();
645
+ return existing;
646
+ }
647
+ const row = em.create(PriceDisplayModeOverride, { scope, targetId, mode });
648
+ await em.persistAndFlush(row);
649
+ this.invalidatePricingCache();
650
+ return row;
651
+ }
652
+ async listDisplayModeOverrides(scope) {
653
+ const em = this.emFactory();
654
+ return em.find(PriceDisplayModeOverride, scope ? { scope } : {}, { orderBy: { scope: 'asc', targetId: 'asc' } });
655
+ }
656
+ async getDisplayModeOverride(scope, targetId) {
657
+ return this.emFactory().findOne(PriceDisplayModeOverride, { scope, targetId });
658
+ }
659
+ /**
660
+ * Update a `pricing.*` settings-group display-mode key. Settings are
661
+ * per-sales-channel under the foundation settings module — when no
662
+ * channel is supplied, this method writes the value across every
663
+ * sales channel (treating it as a tenant-wide override). For
664
+ * channel-specific overrides, callers may pass a single channel.
665
+ *
666
+ * `key` is the bare suffix (e.g. `default_display_mode` or
667
+ * `unauthenticated_display_mode`) — the full code is derived as
668
+ * `pricing.<key>`.
669
+ */
670
+ async setSettingsDisplayMode(key, mode, salesChannelId) {
671
+ // command-coverage-ignore: writes SettingValue rows owned by the settings
672
+ // module (a cross-module config write); settings changes are the settings
673
+ // module's audit concern, not the price-list admin audit log.
674
+ const em = this.emFactory();
675
+ const code = `pricing.${key}`;
676
+ const setting = await em.findOneOrFail(Setting, { code });
677
+ const channels = salesChannelId
678
+ ? [await em.findOneOrFail(SalesChannel, { id: salesChannelId })]
679
+ : await em.find(SalesChannel, {});
680
+ for (const channel of channels) {
681
+ const existing = await em.findOne(SettingValue, {
682
+ setting: setting.id,
683
+ salesChannel: channel.id,
684
+ });
685
+ if (existing) {
686
+ existing.value = mode;
687
+ existing.updatedAt = new Date();
688
+ continue;
689
+ }
690
+ em.create(SettingValue, { setting, salesChannel: channel, value: mode });
691
+ }
692
+ await em.flush();
693
+ }
694
+ /**
695
+ * Read the value of a `pricing.*` display-mode setting for a sales
696
+ * channel. Resolves along the settings tier chain
697
+ * per-channel override → global value → manifest `defaultValue`
698
+ * (mirroring `SettingsService.get`). The global tier lives on
699
+ * `settings.global_value` and is what the admin "All channels" editor
700
+ * writes; without it a globally-set mode (e.g. `both`) was ignored and
701
+ * the resolver silently fell back to the manifest default `gross_only`.
702
+ */
703
+ async readSettingsDisplayMode(key, salesChannelId) {
704
+ const em = this.emFactory();
705
+ const code = `pricing.${key}`;
706
+ const setting = await em.findOne(Setting, { code });
707
+ if (!setting)
708
+ return 'gross_only';
709
+ const value = await em.findOne(SettingValue, {
710
+ setting: setting.id,
711
+ salesChannel: salesChannelId,
712
+ });
713
+ const raw = (value?.value ?? setting.globalValue ?? setting.defaultValue);
714
+ if (raw === 'gross_only' || raw === 'net_only' || raw === 'both' || raw === 'none') {
715
+ return raw;
716
+ }
717
+ return 'gross_only';
718
+ }
719
+ /**
720
+ * Resolve the effective display mode for a (product, organization?,
721
+ * salesChannel) tuple along the FR-039 chain
722
+ * Product → Category → Organization → Settings.
723
+ *
724
+ * Walks the product's category memberships to find the most specific
725
+ * category override (deepest in the tree, with `(sort_order ASC, id ASC)`
726
+ * as the deterministic tie-break). Reads override rows from
727
+ * `price_display_mode_overrides`; falls back to the per-channel
728
+ * settings value (`default_display_mode` for signed-in customers,
729
+ * `unauthenticated_display_mode` for guests).
730
+ */
731
+ async resolveDisplayMode(input) {
732
+ const em = this.emFactory();
733
+ // 1. Product-level override.
734
+ const productOverride = await em.findOne(PriceDisplayModeOverride, {
735
+ scope: 'product',
736
+ targetId: input.productId,
737
+ });
738
+ // 2. Category-level override — the candidates the chain ranks.
739
+ let categoryCandidates = [];
740
+ if (!productOverride) {
741
+ const assignments = await this.targets().catalogCategoryRead.listAssignmentsForProducts([
742
+ input.productId,
743
+ ]);
744
+ if (assignments.length > 0) {
745
+ const categoryIds = assignments.map((a) => a.categoryId);
746
+ const overrides = await em.find(PriceDisplayModeOverride, {
747
+ scope: 'category',
748
+ targetId: { $in: categoryIds },
749
+ });
750
+ categoryCandidates = await this.rankCategoryOverrides(overrides);
751
+ }
752
+ }
753
+ // 3. Organization-level override (only signed-in customers).
754
+ const needsOrganization = !productOverride &&
755
+ categoryCandidates.length === 0 &&
756
+ input.customerKind === 'signed_in' &&
757
+ input.organizationId !== null;
758
+ const organizationOverride = needsOrganization
759
+ ? await em.findOne(PriceDisplayModeOverride, {
760
+ scope: 'organization',
761
+ targetId: input.organizationId,
762
+ })
763
+ : null;
764
+ const decision = decideDisplayMode({
765
+ productOverride: productOverride?.mode ?? null,
766
+ categoryCandidates,
767
+ organizationOverride: organizationOverride?.mode ?? null,
768
+ });
769
+ if (decision.source !== 'settings')
770
+ return decision.mode;
771
+ // 4. Settings fallback.
772
+ return this.readSettingsDisplayMode(settingsDisplayModeKey(input.customerKind), input.salesChannelId);
773
+ }
774
+ /**
775
+ * The chain's answer with **no product in hand** — feature 086 / FR-016.
776
+ *
777
+ * A price ordering and a price range are refused where the page may not show
778
+ * prices at all, and `pricing.unauthenticated_display_mode = none` is the
779
+ * supported "hide prices until login" configuration. Deciding that needs the
780
+ * mode a *page* resolves to, which is the Organization → Settings tail of the
781
+ * FR-039 chain with the product and category steps deliberately absent.
782
+ *
783
+ * **The absence is the ruling, not a shortcut** (spec 086, clarification 2).
784
+ * A single product overridden to `none` keeps its position in a price
785
+ * ordering: a per-product override reads as "ask us for a quote" rather than
786
+ * "this price is secret", and withdrawing the whole control because one
787
+ * product opts out would make the sort appear and disappear as a buyer walks
788
+ * the catalogue. Moving those products to the tail instead is a change to
789
+ * this method's two omitted steps and to nothing else.
790
+ *
791
+ * The decision itself still goes through `decideDisplayMode`, so a page and a
792
+ * card cannot rank the organisation tier differently.
793
+ */
794
+ async resolvePageDisplayMode(input) {
795
+ const em = this.emFactory();
796
+ const organizationOverride = input.customerKind === 'signed_in' && input.organizationId !== null
797
+ ? await em.findOne(PriceDisplayModeOverride, {
798
+ scope: 'organization',
799
+ targetId: input.organizationId,
800
+ })
801
+ : null;
802
+ const decision = decideDisplayMode({
803
+ productOverride: null,
804
+ categoryCandidates: [],
805
+ organizationOverride: organizationOverride?.mode ?? null,
806
+ });
807
+ if (decision.source !== 'settings')
808
+ return decision.mode;
809
+ return this.readSettingsDisplayMode(settingsDisplayModeKey(input.customerKind), input.salesChannelId);
810
+ }
811
+ /**
812
+ * The same chain for a **set** of products (issue #132 follow-up).
813
+ *
814
+ * A catalogue listing asked this question once per product, which cost four
815
+ * to six queries per card on the page's hottest read. Every step is a set
816
+ * lookup — the product-scope overrides, the category memberships, the
817
+ * category-scope overrides, the one organization row and the one settings
818
+ * pair — so the whole page costs what a single product used to.
819
+ *
820
+ * The *decision* is not duplicated: both this method and `resolveDisplayMode`
821
+ * hand their four inputs to `decideDisplayMode`, so a batched page cannot
822
+ * rank an override differently from a product page. Only the loading differs.
823
+ *
824
+ * `categoryIdsByProduct` is an optional prefetch for a caller that has
825
+ * already read the memberships — `PricingService.resolveListingPrices` needs
826
+ * them for the application rules and would otherwise read the same table
827
+ * twice per page.
828
+ */
829
+ async resolveDisplayModes(input) {
830
+ const out = new Map();
831
+ const productIds = [...new Set(input.productIds)];
832
+ if (productIds.length === 0)
833
+ return out;
834
+ const em = this.emFactory();
835
+ // 1. Product-scope overrides for the whole set.
836
+ const productOverrides = new Map();
837
+ for (const row of await em.find(PriceDisplayModeOverride, {
838
+ scope: 'product',
839
+ targetId: { $in: productIds },
840
+ })) {
841
+ productOverrides.set(row.targetId, row.mode);
842
+ }
843
+ // 2. Category memberships, then the category-scope overrides over their
844
+ // union — one lookup for the page rather than one per card.
845
+ const undecided = productIds.filter((id) => !productOverrides.has(id));
846
+ const categoryIdsByProduct = input.categoryIdsByProduct ?? (await this.loadCategoryMemberships(undecided));
847
+ const categoryUnion = new Set();
848
+ for (const productId of undecided) {
849
+ for (const categoryId of categoryIdsByProduct.get(productId) ?? []) {
850
+ categoryUnion.add(categoryId);
851
+ }
852
+ }
853
+ const rankedByCategory = new Map();
854
+ if (categoryUnion.size > 0) {
855
+ const overrides = await em.find(PriceDisplayModeOverride, {
856
+ scope: 'category',
857
+ targetId: { $in: [...categoryUnion] },
858
+ });
859
+ for (const candidate of await this.rankCategoryOverrides(overrides)) {
860
+ rankedByCategory.set(candidate.categoryId, candidate);
861
+ }
862
+ }
863
+ // 3. The organization row is one row for the whole set.
864
+ let organizationOverride = null;
865
+ if (input.customerKind === 'signed_in' && input.organizationId) {
866
+ const row = await em.findOne(PriceDisplayModeOverride, {
867
+ scope: 'organization',
868
+ targetId: input.organizationId,
869
+ });
870
+ organizationOverride = row?.mode ?? null;
871
+ }
872
+ // 4. Decide, and read the settings pair once if anything still needs it.
873
+ const needingSettings = [];
874
+ for (const productId of productIds) {
875
+ const candidates = [];
876
+ for (const categoryId of categoryIdsByProduct.get(productId) ?? []) {
877
+ const ranked = rankedByCategory.get(categoryId);
878
+ if (ranked)
879
+ candidates.push(ranked);
880
+ }
881
+ const decision = decideDisplayMode({
882
+ productOverride: productOverrides.get(productId) ?? null,
883
+ categoryCandidates: candidates,
884
+ organizationOverride,
885
+ });
886
+ if (decision.source === 'settings')
887
+ needingSettings.push(productId);
888
+ else
889
+ out.set(productId, decision.mode);
890
+ }
891
+ if (needingSettings.length > 0) {
892
+ const fallback = await this.readSettingsDisplayMode(settingsDisplayModeKey(input.customerKind), input.salesChannelId);
893
+ for (const productId of needingSettings)
894
+ out.set(productId, fallback);
895
+ }
896
+ return out;
897
+ }
898
+ /**
899
+ * `(product, category)` memberships for a set of products, read through
900
+ * `catalog`'s port (feature 075 / D-87).
901
+ *
902
+ * One call for the whole set, not one per product: the port takes the array
903
+ * for exactly this reason, and a page turning into a round trip per card is
904
+ * the regression this shape exists to prevent.
905
+ *
906
+ * **Structural, deliberately** — no `activeOnly`. The single-product path
907
+ * ranks the same memberships, and `evaluateApplicationRule`'s `category`
908
+ * criterion tests the raw membership, so narrowing to live categories here
909
+ * would make the display-mode chain and the rule that prices the product
910
+ * disagree about which categories a product is in.
911
+ *
912
+ * Every product asked about gets an entry, so an uncategorised product is an
913
+ * empty set rather than a missing key.
914
+ */
915
+ async loadCategoryMemberships(productIds) {
916
+ const out = new Map();
917
+ for (const id of productIds)
918
+ out.set(id, new Set());
919
+ if (productIds.length === 0)
920
+ return out;
921
+ for (const assignment of await this.targets().catalogCategoryRead.listAssignmentsForProducts(productIds)) {
922
+ out.get(assignment.productId)?.add(assignment.categoryId);
923
+ }
924
+ return out;
925
+ }
926
+ /**
927
+ * Turn category-scope override rows into the chain's ranking inputs.
928
+ *
929
+ * `ancestorsOf` answers the category **and** its ancestors, nearest-first, so
930
+ * one port call replaces the `findOne` plus the parent-pointer loop that
931
+ * walked `catalog`'s table a level at a time. Depth is the chain length minus
932
+ * the category itself; an unknown id answers an empty chain and is dropped,
933
+ * which is what the old `continue` did.
934
+ */
935
+ async rankCategoryOverrides(overrides) {
936
+ const out = [];
937
+ for (const override of overrides) {
938
+ const chain = await this.targets().catalogCategoryRead.ancestorsOf(override.targetId);
939
+ const category = chain[0];
940
+ if (!category)
941
+ continue;
942
+ out.push({
943
+ mode: override.mode,
944
+ depth: chain.length - 1,
945
+ sortOrder: category.sortOrder,
946
+ categoryId: category.id,
947
+ });
948
+ }
949
+ return out;
950
+ }
951
+ /**
952
+ * Validate that a polymorphic override target exists in the appropriate
953
+ * table. Throws 400 VALIDATION_FAILED on orphan target IDs (FR-038).
954
+ */
955
+ async assertOverrideTargetExists(scope, targetId) {
956
+ let exists;
957
+ switch (scope) {
958
+ case 'organization':
959
+ exists = await this.targets().organizationDetails.countByIds([targetId]);
960
+ break;
961
+ case 'category':
962
+ exists = await this.targets().catalogCategoryRead.countByIds([targetId]);
963
+ break;
964
+ case 'product':
965
+ exists = await this.targets().catalogProductRead.countByIds([targetId]);
966
+ break;
967
+ }
968
+ if (exists === 0) {
969
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `No ${scope} found with id ${targetId} for display-mode override.`);
970
+ }
971
+ }
972
+ // ---- Engine: product roster + bracket pricing (US3) ----------------
973
+ /**
974
+ * List the assigned products for a price list, with their per-currency
975
+ * brackets in `minQuantity` ascending order. Powers the admin Products
976
+ * tab and the linked-price-lists panel from US8.
977
+ */
978
+ async listProducts(priceListId) {
979
+ await this.getById(priceListId);
980
+ const em = this.emFactory();
981
+ const assignments = await em.find(PriceListProduct, { priceListId });
982
+ if (assignments.length === 0)
983
+ return [];
984
+ const productIds = assignments.map((a) => a.productId);
985
+ const brackets = await em.find(PriceListPriceBracket, { priceListId, productId: { $in: productIds } }, { orderBy: { currencyCode: 'asc', minQuantity: 'asc' } });
986
+ const byProduct = new Map();
987
+ for (const id of productIds)
988
+ byProduct.set(id, {});
989
+ for (const b of brackets) {
990
+ const buckets = byProduct.get(b.productId);
991
+ if (!buckets)
992
+ continue;
993
+ (buckets[b.currencyCode] ??= []).push({
994
+ minQuantity: b.minQuantity,
995
+ maxQuantity: b.maxQuantity ?? null,
996
+ amount: b.amount,
997
+ });
998
+ }
999
+ return assignments.map((a) => ({
1000
+ productId: a.productId,
1001
+ bracketsByCurrency: byProduct.get(a.productId) ?? {},
1002
+ }));
1003
+ }
1004
+ /**
1005
+ * Idempotent append: assigns a single product to a price list. No-op when
1006
+ * the assignment already exists. The list's `modifiedAt` is bumped only
1007
+ * when the assignment is actually new.
1008
+ */
1009
+ async addProduct(priceListId, productId) {
1010
+ await this.#runAudited('price_list.product_add', priceListId, undefined, async (em) => {
1011
+ const list = await this.getById(priceListId, em);
1012
+ const existing = await em.findOne(PriceListProduct, { priceListId, productId });
1013
+ if (existing)
1014
+ return { result: undefined, row: list, before: null, after: null, skipAudit: true };
1015
+ em.create(PriceListProduct, { priceListId, productId });
1016
+ list.modifiedAt = new Date();
1017
+ return { result: undefined, row: list, before: null, after: { name: list.name, productId } };
1018
+ });
1019
+ }
1020
+ /**
1021
+ * Removes a product's assignment + its bracket rows (cascade via FK).
1022
+ * No-op when the assignment does not exist.
1023
+ */
1024
+ async removeProduct(priceListId, productId) {
1025
+ await this.#runAudited('price_list.product_remove', priceListId, undefined, async (em) => {
1026
+ const list = await this.getById(priceListId, em);
1027
+ const existing = await em.findOne(PriceListProduct, { priceListId, productId });
1028
+ if (!existing)
1029
+ return { result: undefined, row: list, before: null, after: null, skipAudit: true };
1030
+ em.remove(existing);
1031
+ list.modifiedAt = new Date();
1032
+ return { result: undefined, row: list, before: null, after: { name: list.name, productId } };
1033
+ });
1034
+ }
1035
+ /**
1036
+ * Bulk delta replacement of the product roster. Adds new pairs, removes
1037
+ * pairs absent from the request. Returns the {added, removed, unchanged}
1038
+ * counts. Cascade deletes brackets for removed pairs.
1039
+ */
1040
+ async replaceProducts(priceListId, productIds, auditCtx) {
1041
+ return this.#runAudited('price_list.products_replace', priceListId, auditCtx, async (em) => {
1042
+ const list = await this.getById(priceListId, em);
1043
+ const existing = await em.find(PriceListProduct, { priceListId });
1044
+ const existingSet = new Set(existing.map((e) => e.productId));
1045
+ const wanted = new Set(productIds);
1046
+ const toAdd = [...wanted].filter((id) => !existingSet.has(id));
1047
+ const toRemove = existing.filter((e) => !wanted.has(e.productId));
1048
+ const unchanged = [...wanted].filter((id) => existingSet.has(id));
1049
+ for (const id of toAdd)
1050
+ em.create(PriceListProduct, { priceListId, productId: id });
1051
+ for (const row of toRemove)
1052
+ em.remove(row);
1053
+ const result = { added: toAdd.length, removed: toRemove.length, unchanged: unchanged.length };
1054
+ if (toAdd.length === 0 && toRemove.length === 0) {
1055
+ return { result, row: list, before: null, after: null, skipAudit: true };
1056
+ }
1057
+ list.modifiedAt = new Date();
1058
+ // Feature 024 — a single summary audit row carrying only counts
1059
+ // (never the full product-id list — FR-013).
1060
+ return {
1061
+ result,
1062
+ row: list,
1063
+ before: null,
1064
+ after: {
1065
+ name: list.name,
1066
+ added: toAdd.length,
1067
+ removed: toRemove.length,
1068
+ kept: unchanged.length,
1069
+ },
1070
+ };
1071
+ });
1072
+ }
1073
+ /**
1074
+ * Bulk replacement of bracket rows for one (list, product) pair across
1075
+ * every currency in the request. Currencies absent from the input are
1076
+ * cleared. Brackets are validated for:
1077
+ * - assignment existence (404 when product is not in the list);
1078
+ * - per-row sanity: minQuantity ≥ 1, maxQuantity ≥ minQuantity (when
1079
+ * present), amount ≥ 0;
1080
+ * - per-currency overlap freedom (FR-015).
1081
+ *
1082
+ * The whole save runs in one EM flush so a partial save is impossible.
1083
+ */
1084
+ async replaceBrackets(priceListId, productId, bracketsByCurrency, auditCtx) {
1085
+ return this.#runAudited('price_list.bracket_update', priceListId, auditCtx, async (em) => {
1086
+ const list = await this.getById(priceListId, em);
1087
+ const assignment = await em.findOne(PriceListProduct, { priceListId, productId });
1088
+ if (!assignment) {
1089
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, `Product ${productId} is not assigned to price list ${priceListId}.`);
1090
+ }
1091
+ const validated = {};
1092
+ for (const [currencyRaw, rows] of Object.entries(bracketsByCurrency)) {
1093
+ const currency = currencyRaw.toUpperCase();
1094
+ if (!/^[A-Z]{3}$/.test(currency)) {
1095
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `Invalid currency code: ${currencyRaw}`);
1096
+ }
1097
+ const sorted = [...rows].sort((a, b) => a.minQuantity - b.minQuantity);
1098
+ this.assertBracketSanity(currency, sorted);
1099
+ this.assertNoOverlap(currency, sorted);
1100
+ validated[currency] = sorted;
1101
+ }
1102
+ // Replace: remove every existing bracket for the (list, product) pair
1103
+ // FIRST, flush, then insert the new rows. The two-flush split keeps
1104
+ // MikroORM's UoW from collapsing identical-PK delete+insert pairs.
1105
+ const existing = await em.find(PriceListPriceBracket, { priceListId, productId });
1106
+ for (const row of existing)
1107
+ em.remove(row);
1108
+ if (existing.length > 0) {
1109
+ await em.flush();
1110
+ }
1111
+ const out = {};
1112
+ for (const [currency, rows] of Object.entries(validated)) {
1113
+ const inserted = [];
1114
+ for (const r of rows) {
1115
+ em.create(PriceListPriceBracket, {
1116
+ priceListId,
1117
+ productId,
1118
+ currencyCode: currency,
1119
+ minQuantity: r.minQuantity,
1120
+ maxQuantity: r.maxQuantity ?? null,
1121
+ amount: r.amount,
1122
+ });
1123
+ inserted.push({
1124
+ minQuantity: r.minQuantity,
1125
+ maxQuantity: r.maxQuantity ?? null,
1126
+ amount: r.amount,
1127
+ });
1128
+ }
1129
+ out[currency] = inserted;
1130
+ }
1131
+ list.modifiedAt = new Date();
1132
+ // Feature 024 — one audit row per (list, product) bracket save, with
1133
+ // per-currency bracket counts in the summary (never the raw amounts).
1134
+ const summary = {};
1135
+ for (const [currency, rows] of Object.entries(out)) {
1136
+ summary[currency] = rows.length;
1137
+ }
1138
+ return {
1139
+ result: out,
1140
+ row: list,
1141
+ before: null,
1142
+ after: { name: list.name, productId, bracketCountByCurrency: summary },
1143
+ };
1144
+ });
1145
+ }
1146
+ /**
1147
+ * Convenience: copy one currency's brackets into other currencies for the
1148
+ * same (list, product) pair. The target currencies' existing brackets (if
1149
+ * any) are NOT cleared — this method appends. Conflicts on
1150
+ * `(currency, minQuantity)` are refused via the PK constraint (caller
1151
+ * should normally use this on currencies with no brackets yet, e.g. via
1152
+ * the admin "Copy currency" affordance).
1153
+ */
1154
+ async copyCurrencyBrackets(priceListId, productId, fromCurrency, toCurrencies) {
1155
+ return this.#runAudited('price_list.bracket_copy', priceListId, undefined, async (em) => {
1156
+ const list = await this.getById(priceListId, em);
1157
+ const assignment = await em.findOne(PriceListProduct, { priceListId, productId });
1158
+ if (!assignment) {
1159
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, `Product ${productId} is not assigned to price list ${priceListId}.`);
1160
+ }
1161
+ const source = await em.find(PriceListPriceBracket, {
1162
+ priceListId,
1163
+ productId,
1164
+ currencyCode: fromCurrency.toUpperCase(),
1165
+ });
1166
+ if (source.length === 0) {
1167
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, `Source currency ${fromCurrency} has no brackets for this product.`);
1168
+ }
1169
+ let added = 0;
1170
+ for (const targetRaw of toCurrencies) {
1171
+ const target = targetRaw.toUpperCase();
1172
+ if (target === fromCurrency.toUpperCase())
1173
+ continue;
1174
+ if (!/^[A-Z]{3}$/.test(target)) {
1175
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `Invalid currency code: ${targetRaw}`);
1176
+ }
1177
+ for (const b of source) {
1178
+ const conflict = await em.findOne(PriceListPriceBracket, {
1179
+ priceListId,
1180
+ productId,
1181
+ currencyCode: target,
1182
+ minQuantity: b.minQuantity,
1183
+ });
1184
+ if (conflict)
1185
+ continue;
1186
+ em.create(PriceListPriceBracket, {
1187
+ priceListId,
1188
+ productId,
1189
+ currencyCode: target,
1190
+ minQuantity: b.minQuantity,
1191
+ maxQuantity: b.maxQuantity ?? null,
1192
+ amount: b.amount,
1193
+ });
1194
+ added += 1;
1195
+ }
1196
+ }
1197
+ const result = { added };
1198
+ if (added === 0)
1199
+ return { result, row: list, before: null, after: null, skipAudit: true };
1200
+ list.modifiedAt = new Date();
1201
+ return { result, row: list, before: null, after: { name: list.name, productId, added } };
1202
+ });
1203
+ }
1204
+ /**
1205
+ * Per-row bracket sanity (FR-014, FR-015).
1206
+ */
1207
+ assertBracketSanity(currency, rows) {
1208
+ for (const [i, r] of rows.entries()) {
1209
+ if (!Number.isInteger(r.minQuantity) || r.minQuantity < 1) {
1210
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `Bracket ${currency}[${i}]: minQuantity must be an integer ≥ 1 (got ${r.minQuantity}).`);
1211
+ }
1212
+ if (r.maxQuantity != null) {
1213
+ if (!Number.isInteger(r.maxQuantity) || r.maxQuantity < r.minQuantity) {
1214
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `Bracket ${currency}[${i}]: maxQuantity (${r.maxQuantity}) must be an integer ≥ minQuantity (${r.minQuantity}).`);
1215
+ }
1216
+ }
1217
+ const amount = Number(r.amount);
1218
+ if (!Number.isFinite(amount) || amount < 0) {
1219
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `Bracket ${currency}[${i}]: amount must be ≥ 0 (got ${r.amount}).`);
1220
+ }
1221
+ }
1222
+ }
1223
+ /**
1224
+ * Overlap-freedom within a currency series (FR-015). Assumes the input is
1225
+ * sorted ascending by `minQuantity`.
1226
+ */
1227
+ assertNoOverlap(currency, sorted) {
1228
+ for (let i = 1; i < sorted.length; i += 1) {
1229
+ const prev = sorted[i - 1];
1230
+ const curr = sorted[i];
1231
+ const prevMax = prev.maxQuantity ?? Number.POSITIVE_INFINITY;
1232
+ if (prevMax >= curr.minQuantity) {
1233
+ throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `Bracket overlap in ${currency}: [${prev.minQuantity}..${prev.maxQuantity ?? '∞'}] overlaps [${curr.minQuantity}..${curr.maxQuantity ?? '∞'}].`);
1234
+ }
1235
+ }
1236
+ }
1237
+ }
1238
+ //# sourceMappingURL=price-list-service.js.map