@mj-biz-apps/sales-core-entities-server 5.1.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 (106) hide show
  1. package/LICENSE +183 -0
  2. package/dist/CloseDealOperation.d.ts +176 -0
  3. package/dist/CloseDealOperation.d.ts.map +1 -0
  4. package/dist/CloseDealOperation.js +1107 -0
  5. package/dist/CloseDealOperation.js.map +1 -0
  6. package/dist/CloseWonTaskService.d.ts +249 -0
  7. package/dist/CloseWonTaskService.d.ts.map +1 -0
  8. package/dist/CloseWonTaskService.js +460 -0
  9. package/dist/CloseWonTaskService.js.map +1 -0
  10. package/dist/DealEntityServer.d.ts +625 -0
  11. package/dist/DealEntityServer.d.ts.map +1 -0
  12. package/dist/DealEntityServer.js +1529 -0
  13. package/dist/DealEntityServer.js.map +1 -0
  14. package/dist/LiveContractsSeam.d.ts +212 -0
  15. package/dist/LiveContractsSeam.d.ts.map +1 -0
  16. package/dist/LiveContractsSeam.js +362 -0
  17. package/dist/LiveContractsSeam.js.map +1 -0
  18. package/dist/LiveOrdersSeam.d.ts +68 -0
  19. package/dist/LiveOrdersSeam.d.ts.map +1 -0
  20. package/dist/LiveOrdersSeam.js +80 -0
  21. package/dist/LiveOrdersSeam.js.map +1 -0
  22. package/dist/SaveDealOperation.d.ts +45 -0
  23. package/dist/SaveDealOperation.d.ts.map +1 -0
  24. package/dist/SaveDealOperation.js +268 -0
  25. package/dist/SaveDealOperation.js.map +1 -0
  26. package/dist/SequenceService.d.ts +39 -0
  27. package/dist/SequenceService.d.ts.map +1 -0
  28. package/dist/SequenceService.js +44 -0
  29. package/dist/SequenceService.js.map +1 -0
  30. package/dist/activities/ActivityIngestService.d.ts +111 -0
  31. package/dist/activities/ActivityIngestService.d.ts.map +1 -0
  32. package/dist/activities/ActivityIngestService.js +485 -0
  33. package/dist/activities/ActivityIngestService.js.map +1 -0
  34. package/dist/activities/ActivityReader.d.ts +68 -0
  35. package/dist/activities/ActivityReader.d.ts.map +1 -0
  36. package/dist/activities/ActivityReader.js +147 -0
  37. package/dist/activities/ActivityReader.js.map +1 -0
  38. package/dist/activities/ActivitySource.d.ts +142 -0
  39. package/dist/activities/ActivitySource.d.ts.map +1 -0
  40. package/dist/activities/ActivitySource.js +2 -0
  41. package/dist/activities/ActivitySource.js.map +1 -0
  42. package/dist/activities/ActivitySyncJob.d.ts +72 -0
  43. package/dist/activities/ActivitySyncJob.d.ts.map +1 -0
  44. package/dist/activities/ActivitySyncJob.js +146 -0
  45. package/dist/activities/ActivitySyncJob.js.map +1 -0
  46. package/dist/activities/ActivityWriterService.d.ts +119 -0
  47. package/dist/activities/ActivityWriterService.d.ts.map +1 -0
  48. package/dist/activities/ActivityWriterService.js +270 -0
  49. package/dist/activities/ActivityWriterService.js.map +1 -0
  50. package/dist/activities/DealMatcher.d.ts +58 -0
  51. package/dist/activities/DealMatcher.d.ts.map +1 -0
  52. package/dist/activities/DealMatcher.js +109 -0
  53. package/dist/activities/DealMatcher.js.map +1 -0
  54. package/dist/activities/FixtureActivitySource.d.ts +37 -0
  55. package/dist/activities/FixtureActivitySource.d.ts.map +1 -0
  56. package/dist/activities/FixtureActivitySource.js +58 -0
  57. package/dist/activities/FixtureActivitySource.js.map +1 -0
  58. package/dist/activities/GraphMessageMapper.d.ts +47 -0
  59. package/dist/activities/GraphMessageMapper.d.ts.map +1 -0
  60. package/dist/activities/GraphMessageMapper.js +141 -0
  61. package/dist/activities/GraphMessageMapper.js.map +1 -0
  62. package/dist/activities/ImportedGraphActivitySource.d.ts +16 -0
  63. package/dist/activities/ImportedGraphActivitySource.d.ts.map +1 -0
  64. package/dist/activities/ImportedGraphActivitySource.js +84 -0
  65. package/dist/activities/ImportedGraphActivitySource.js.map +1 -0
  66. package/dist/activities/MSGraphActivitySource.d.ts +67 -0
  67. package/dist/activities/MSGraphActivitySource.d.ts.map +1 -0
  68. package/dist/activities/MSGraphActivitySource.js +186 -0
  69. package/dist/activities/MSGraphActivitySource.js.map +1 -0
  70. package/dist/activities/MSGraphCalendarSource.d.ts +72 -0
  71. package/dist/activities/MSGraphCalendarSource.d.ts.map +1 -0
  72. package/dist/activities/MSGraphCalendarSource.js +225 -0
  73. package/dist/activities/MSGraphCalendarSource.js.map +1 -0
  74. package/dist/activities/RelevanceFilter.d.ts +101 -0
  75. package/dist/activities/RelevanceFilter.d.ts.map +1 -0
  76. package/dist/activities/RelevanceFilter.js +148 -0
  77. package/dist/activities/RelevanceFilter.js.map +1 -0
  78. package/dist/activities/activity-vocabulary.d.ts +103 -0
  79. package/dist/activities/activity-vocabulary.d.ts.map +1 -0
  80. package/dist/activities/activity-vocabulary.js +79 -0
  81. package/dist/activities/activity-vocabulary.js.map +1 -0
  82. package/dist/forecast/FixtureForecastSource.d.ts +22 -0
  83. package/dist/forecast/FixtureForecastSource.d.ts.map +1 -0
  84. package/dist/forecast/FixtureForecastSource.js +16 -0
  85. package/dist/forecast/FixtureForecastSource.js.map +1 -0
  86. package/dist/forecast/ForecastSnapshotJob.d.ts +78 -0
  87. package/dist/forecast/ForecastSnapshotJob.d.ts.map +1 -0
  88. package/dist/forecast/ForecastSnapshotJob.js +225 -0
  89. package/dist/forecast/ForecastSnapshotJob.js.map +1 -0
  90. package/dist/forecast/ForecastSource.d.ts +74 -0
  91. package/dist/forecast/ForecastSource.d.ts.map +1 -0
  92. package/dist/forecast/ForecastSource.js +25 -0
  93. package/dist/forecast/ForecastSource.js.map +1 -0
  94. package/dist/forecast/QueryForecastSource.d.ts +76 -0
  95. package/dist/forecast/QueryForecastSource.d.ts.map +1 -0
  96. package/dist/forecast/QueryForecastSource.js +164 -0
  97. package/dist/forecast/QueryForecastSource.js.map +1 -0
  98. package/dist/index.d.ts +117 -0
  99. package/dist/index.d.ts.map +1 -0
  100. package/dist/index.js +125 -0
  101. package/dist/index.js.map +1 -0
  102. package/dist/orders-availability.d.ts +15 -0
  103. package/dist/orders-availability.d.ts.map +1 -0
  104. package/dist/orders-availability.js +47 -0
  105. package/dist/orders-availability.js.map +1 -0
  106. package/package.json +43 -0
@@ -0,0 +1,1107 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ /**
8
+ * @fileoverview `Sales.CloseDeal` and `Sales.ReopenDeal` — master plan §7.
9
+ *
10
+ * ONE TRANSACTION, ALL-OR-NONE. A partial close — contract created, order missing, lock applied — is
11
+ * the exact failure the transaction exists to prevent (§7.2). Everything below happens inside a single
12
+ * provider transaction, and any throw rolls the whole thing back.
13
+ *
14
+ * ── THE PATH IS RESOLVED FROM FLAGS, NEVER NAMES ────────────────────────────────────────────────
15
+ *
16
+ * The operation is called `CloseDeal`, not `WinDeal`, on purpose. It reads `IsWon` / `IsLost` /
17
+ * `LocksDeal` off the target `DealStatusType` row and branches on those. Nothing here compares a
18
+ * status name, a pipeline name, or the strings "B2B" / "D2C" — a deployment renames those freely and a
19
+ * rename must not change what a close does. Routing comes from `Pipeline.CloseWonPolicy`, which is
20
+ * config-as-data, and from nowhere else. The CI vocabulary grep enforces this.
21
+ *
22
+ * ── SALES STILL COMPUTES NO MONEY ───────────────────────────────────────────────────────────────
23
+ *
24
+ * The close routes lines and passes intent. It does not price them, total them, or apply a discount.
25
+ * Every figure comes back from orders, and while the downstream is stubbed no figure exists at all —
26
+ * which is honest rather than convenient.
27
+ *
28
+ * @module @mj-biz-apps/sales-core-entities-server
29
+ */
30
+ import { BaseRemotableOperation, LogError, } from '@memberjunction/core';
31
+ import { RegisterClass } from '@memberjunction/global';
32
+ /**
33
+ * ORDERS' OWN PREDICATE, imported rather than restated -- the same reason
34
+ * `DealEntityServer` imports `CanTransition` from this module.
35
+ *
36
+ * `IsBooked` is `Confirmed | Posted | Fulfilled`, and orders documents it as "journal entries
37
+ * exist and the receivable is real". Writing `status === 'Confirmed'` here instead would be a
38
+ * vocabulary string-comparison against ANOTHER APP'S words -- exactly what the vocabulary gate
39
+ * exists to catch -- and it would also be wrong twice over. See the refusal below.
40
+ */
41
+ import { IsBooked } from '@mj-biz-apps/orders-entities';
42
+ import { SalesCloseDealOperation as SalesCloseDealOperationBase, SalesReopenDealOperation as SalesReopenDealOperationBase, StubDownstreamSeam, } from '@mj-biz-apps/sales-entities';
43
+ import { ContractsIsInstalled, LiveContractsSeam } from './LiveContractsSeam.js';
44
+ import { CloseWonTaskDueAt, CloseWonTaskService, ReadCloseWonTaskConfig } from './CloseWonTaskService.js';
45
+ const DEAL_ENTITY = 'MJ_BizApps_Sales: Deals';
46
+ const DEAL_STATUS_ENTITY = 'MJ_BizApps_Sales: Deal Status Types';
47
+ const DEAL_TYPE_ENTITY = 'MJ_BizApps_Sales: Deal Types';
48
+ const LOSS_REASON_ENTITY = 'MJ_BizApps_Sales: Loss Reasons';
49
+ const PIPELINE_ENTITY = 'MJ_BizApps_Sales: Pipelines';
50
+ // Read to resolve a closing stage by the FLAG its status carries -- see closingStageForOutcome.
51
+ const STAGE_ENTITY = 'MJ_BizApps_Sales: Pipeline Stages';
52
+ /**
53
+ * READ, never written. This operation stopped writing to the stage log — it declares the transition and
54
+ * `DealEntityServer` appends the one row it owes (see `DeclareTransition`). It reads the log because the
55
+ * log is where the answer to "what stage was this deal in before it closed" already lives.
56
+ */
57
+ const STAGE_EVENT_ENTITY = 'MJ_BizApps_Sales: Deal Stage Events';
58
+ /** Defaults applied when a pipeline's policy is silent on a key. */
59
+ const POLICY_DEFAULTS = {
60
+ CreateContract: false,
61
+ SubscriptionLinesTo: 'None',
62
+ // `OrderState` IS GONE FROM THE POLICY ENTIRELY, and not because it was merely unread.
63
+ //
64
+ // A close-time key could only ever speak at one moment, and only about a won close. The stage is
65
+ // where the answer belongs: `PipelineStage.OrderStatusOnEntry` says what entering ANY stage means
66
+ // for the order -- won, lost, or halfway -- and it is one source of truth rather than a policy key
67
+ // that agreed with the stage table by luck. Ruled by Andrew, `docs/DECISIONS.md` D-OS1. Removed
68
+ // from the published input contract in the same commit, because two spellings of the same idea is
69
+ // the problem being solved.
70
+ //
71
+ // `OneTimeLinesTo` is still DEAD but still here: it steered one-time lines to the Order route,
72
+ // which no longer exists. Narrowing the published contract is its own decision and Andrew has not
73
+ // made it -- see `DECISIONS-NEEDED.md` DN-11. A deployment setting it is configuring nothing.
74
+ OneTimeLinesTo: 'None',
75
+ };
76
+ function issue(Section, Message, Field = null) {
77
+ return { Section, Field, Severity: 'error', Message };
78
+ }
79
+ /**
80
+ * The deal's order-status warnings, as Issues — the channel D-OS1's rule 3 needs.
81
+ *
82
+ * A stage change that asks the order for a status orders refuses must not fail the operation, so the
83
+ * refusal cannot be an error. It also must not be silent, or the one case S-US8 GUARANTEES will happen —
84
+ * a reopened lost deal whose order is Voided — reads as a clean reopen that quietly did half the job.
85
+ *
86
+ * `Severity: 'warning'` is already in the published union, so this needs no contract change.
87
+ */
88
+ function orderStatusIssues(deal) {
89
+ return deal.OrderStatusWarnings.map((Message) => ({
90
+ Section: 'deal',
91
+ Field: 'PipelineStageID',
92
+ Severity: 'warning',
93
+ Message,
94
+ }));
95
+ }
96
+ /**
97
+ * A planned downstream route that did NOT execute, as a warning Issue.
98
+ *
99
+ * ── WHY THIS EXISTS WHEN `Routing` ALREADY CARRIES THE REASON ───────────────────────────────────
100
+ *
101
+ * It carries it for anyone who reads `Routing`. The workspace does — `deal-workspace.component.html`
102
+ * renders `— not created: {{ r.Reason }}` on a `--failed` row — so a REP closing a deal already sees
103
+ * the refusal and why. That surface is fine and is not what this is for.
104
+ *
105
+ * The gap is programmatic. An API client, an agent or an importer that checks `Success` and reads
106
+ * `Issues` — the two fields every other failure mode in this operation reports through — sees
107
+ * `Success: true` and an EMPTY `Issues` array on a close whose contract was refused. Measured on this
108
+ * host: `DEAL-9003` is a renewal with no `RenewsContractID`, the seam declined with a clear reason, and
109
+ * the envelope reported unqualified success. Nothing was wrong with the close; everything was wrong
110
+ * with how a non-browser caller would have read it.
111
+ *
112
+ * ── SEVERITY IS `warning`, AND `Success` IS DELIBERATELY UNTOUCHED ──────────────────────────────
113
+ *
114
+ * A refused downstream route must not fail a won deal. The deal IS won: the status is written, the
115
+ * stage event is stamped, the order is intact. Rolling that back because a contract could not be
116
+ * derived would trade a recorded win for an unrecorded one, and the refusals are the expected cases —
117
+ * a renewal with no parent contract, a subscription route waiting on orders' C0.
118
+ *
119
+ * So this is the same shape as {@link orderStatusIssues} one function above, for the same reason: the
120
+ * thing must be loud without being fatal. `close-deal.CD23` pins BOTH halves, because the regression to
121
+ * fear is not the warning disappearing — it is somebody reading a warning as a bug and "fixing" it by
122
+ * failing the close.
123
+ */
124
+ function routingIssues(routing,
125
+ /**
126
+ * PREVIEW CHANGES WHAT "NOT EXECUTED" MEANS, and getting this wrong told every tester their
127
+ * first close had failed.
128
+ *
129
+ * `execute()` does not run until AFTER the preview returns, so in preview `Executed` is false on
130
+ * every route by construction. Filtering on `Planned && !Executed` therefore reported
131
+ * "Contract was planned but not created: no reason given" on EVERY B2B preview — a failure
132
+ * message for something nobody had attempted yet.
133
+ *
134
+ * Suppressing route reporting in preview would be the wrong correction: a preview exists to say
135
+ * what it plans. But that is what `Routing` is for, and it IS returned in preview, in full.
136
+ * `Issues` is the channel for what is WRONG, and its severity union is `'error' | 'warning'` —
137
+ * there is no informational level to demote a plan into, so a plan does not belong here at all.
138
+ *
139
+ * WHAT STILL WARRANTS A WARNING IN PREVIEW is a route that already knows it cannot run, and the
140
+ * distinction was already in the data. A route carrying a `Reason` AT PLAN TIME has a known
141
+ * blocker — the Subscription route names orders' missing `Subscription.BillingMode` — while a
142
+ * route with no reason is simply one nothing has tried yet. Preview reports the first and stays
143
+ * quiet about the second. That is also what makes a real refusal distinguishable from the
144
+ * ordinary case, which it was not before: every route read the same.
145
+ */
146
+ preview) {
147
+ return routing
148
+ .filter((r) => r.Planned === true && r.Executed !== true)
149
+ .filter((r) => !preview || r.Reason != null)
150
+ .map((r) => ({
151
+ Section: 'deal',
152
+ Field: null,
153
+ Severity: 'warning',
154
+ // Worded to match what the workspace shows, so a rep reading the screen and an agent
155
+ // reading the envelope are quoting the same sentence back at each other.
156
+ Message: preview
157
+ ? `${r.Target} is planned, but will not be created: ${r.Reason}`
158
+ : `${r.Target} was planned but not created: ${r.Reason ?? 'no reason given'}`,
159
+ }));
160
+ }
161
+ /**
162
+ * The seam the close flow calls downstream.
163
+ *
164
+ * A module-level default rather than a constructor argument because remote operations are built by the
165
+ * ClassFactory with no arguments. {@link SetDownstreamSeam} is how a test — or a future real
166
+ * implementation — replaces it.
167
+ */
168
+ let downstreamSeam = new StubDownstreamSeam();
169
+ /** Set once a caller overrides the seam explicitly, so auto-selection stops second-guessing them. */
170
+ let seamWasSetExplicitly = false;
171
+ /** Swap the downstream implementation. Returns the previous one so a test can restore it. */
172
+ export function SetDownstreamSeam(seam) {
173
+ const previous = downstreamSeam;
174
+ downstreamSeam = seam;
175
+ seamWasSetExplicitly = true;
176
+ return previous;
177
+ }
178
+ export function GetDownstreamSeam() {
179
+ return downstreamSeam;
180
+ }
181
+ /**
182
+ * Undo {@link SetDownstreamSeam} and return to DEPLOYMENT-BASED selection.
183
+ *
184
+ * Needed because `SetDownstreamSeam` latches: it records that a caller took control, so auto-selection
185
+ * stops second-guessing them. A test that installs a fake and then merely sets the old value back would
186
+ * leave that latch on, and every later check in the process would keep using whatever it restored —
187
+ * silently, and only on hosts where the live seam would otherwise have been chosen.
188
+ */
189
+ export function ResetDownstreamSeam() {
190
+ downstreamSeam = new StubDownstreamSeam();
191
+ seamWasSetExplicitly = false;
192
+ }
193
+ /**
194
+ * Choose the seam for THIS execution.
195
+ *
196
+ * Deployment decides, not configuration: if orders' entities are registered, the live handoff is used;
197
+ * if they are not — sales installed standalone, which is supported — the stub records the intent and
198
+ * reports `Executed: false`. An explicit {@link SetDownstreamSeam} always wins, because a test that
199
+ * installed a fake must not have it silently replaced by the real thing.
200
+ *
201
+ * Built per call rather than cached because it closes over the acting user, and a seam that reused the
202
+ * first caller's identity would write orders' records as the wrong person.
203
+ */
204
+ function resolveSeam(user, provider) {
205
+ if (seamWasSetExplicitly) {
206
+ return downstreamSeam;
207
+ }
208
+ /**
209
+ * ── THERE IS ONLY ONE LIVE DOWNSTREAM LEFT, SO THERE IS ONLY ONE THING TO RESOLVE ───────────────
210
+ *
211
+ * This used to compose two halves: an orders seam wrapping a contracts seam, plus a stub-orders
212
+ * variant for a contracts-without-orders host. All of that existed because close-won CREATED the
213
+ * order a won deal earned. It does not any more — the order is embedded on the deal from creation —
214
+ * so `LiveOrdersSeam` lost its two real methods and ended as a 128-line pass-through that forwarded
215
+ * the contract calls to the seam it had been handed. Wrapping a seam in a class that only forwards to
216
+ * it is not composition; it is a layer to read past. Both it and `StubOrdersWithContracts` are gone.
217
+ *
218
+ * The independence the old comment defended is preserved and is now simply true by construction:
219
+ * contracts resolves on its own flag and orders is not consulted here at all. A host with contracts
220
+ * and no orders gets the live contracts seam, which is exactly what `StubOrdersWithContracts` was
221
+ * built to arrange.
222
+ *
223
+ * `OrdersIsInstalled()` still matters to the close — it decides whether an orders-dependent route is
224
+ * available — and now lives in `orders-availability.js`, because it answers a question about the HOST
225
+ * rather than about a seam.
226
+ */
227
+ return ContractsIsInstalled() ? new LiveContractsSeam(user, provider) : new StubDownstreamSeam();
228
+ }
229
+ let CloseDealOperation = class CloseDealOperation extends SalesCloseDealOperationBase {
230
+ async InternalExecute(input, provider, user) {
231
+ const empty = {
232
+ Success: false,
233
+ Issues: [],
234
+ IsWon: false,
235
+ IsLost: false,
236
+ Locked: false,
237
+ Routing: [],
238
+ WasPreview: input?.PreviewOnly === true,
239
+ };
240
+ if (!input?.DealID) {
241
+ return { ...empty, Issues: [issue('deal', 'A deal is required.', 'DealID')] };
242
+ }
243
+ if (!input.DealStatusTypeID) {
244
+ return { ...empty, Issues: [issue('deal', 'A target status is required.', 'DealStatusTypeID')] };
245
+ }
246
+ const db = provider;
247
+ let transactionOpen = false;
248
+ try {
249
+ // ── 1. Load the deal and resolve the target status FLAGS ──────────────
250
+ const deal = await provider.GetEntityObject(DEAL_ENTITY, user);
251
+ if (!(await deal.Load(input.DealID))) {
252
+ return { ...empty, Issues: [issue('deal', `Deal ${input.DealID} was not found.`, 'DealID')] };
253
+ }
254
+ const target = await this.loadStatusFlags(input.DealStatusTypeID, provider, user);
255
+ if (!target) {
256
+ return {
257
+ ...empty,
258
+ Issues: [issue('deal', `Status ${input.DealStatusTypeID} was not found.`, 'DealStatusTypeID')],
259
+ };
260
+ }
261
+ // Already locked? Refuse before doing anything — reopening is the sanctioned path.
262
+ const current = deal.DealStatusTypeID
263
+ ? await this.loadStatusFlags(deal.DealStatusTypeID, provider, user)
264
+ : null;
265
+ if (current?.LocksDeal) {
266
+ return {
267
+ ...empty,
268
+ Issues: [
269
+ issue('deal', 'This deal is already closed and locked. Reopen it through Sales.ReopenDeal first.'),
270
+ ],
271
+ };
272
+ }
273
+ // ── 2. Validate what this particular close requires ───────────────────
274
+ const validation = await this.validate(deal, input, target, provider, user);
275
+ if (validation.length) {
276
+ return { ...empty, IsWon: target.IsWon, IsLost: target.IsLost, Issues: validation };
277
+ }
278
+ // ── 3. Resolve the effective policy, and route ────────────────────────
279
+ // A LOST deal routes nowhere: §7.2 step 2 writes the stage event, locks, and is done.
280
+ const policy = target.IsWon
281
+ ? await this.resolvePolicy(deal, input, provider, user)
282
+ : { ...POLICY_DEFAULTS };
283
+ const routing = target.IsWon
284
+ ? await this.planRouting(deal, policy, provider, user)
285
+ : [];
286
+ /**
287
+ * THE UNROUTABLE-LINE REFUSAL (KI-14) WAS HERE, and it is gone with the table it guarded.
288
+ *
289
+ * It refused a close when a line carried a product NAME with no `ProductID`, because both
290
+ * builders coerced that to `ProductID: ''` and an empty string against a `uniqueidentifier`
291
+ * failed inside the sibling's save — after the status had been written. Deal won, nothing
292
+ * downstream.
293
+ *
294
+ * That shape cannot be constructed any more. `OrderLine.ProductID` is `NOT NULL` with a real
295
+ * FK to the catalogue, so a line without a product does not exist to be refused. Recorded as
296
+ * deliberate in docs/DECISIONS.md D-DL1 rather than left to be noticed missing.
297
+ *
298
+ * The HubSpot half of KI-14 survives as an IMPORT concern: a deal imported with a product
299
+ * name and no catalogue ID now fails when the importer tries to write the order line, which
300
+ * is the right place for it to fail.
301
+ */
302
+ if (input.PreviewOnly) {
303
+ // Nothing written, nothing locked — the caller just wanted to see the consequences.
304
+ // A refusal IS one of those consequences, so the preview reports it too: a caller that
305
+ // previews before closing should not learn about it only after committing.
306
+ return {
307
+ Success: true,
308
+ Issues: routingIssues(routing, true),
309
+ IsWon: target.IsWon,
310
+ IsLost: target.IsLost,
311
+ Locked: false,
312
+ EffectivePolicy: policy,
313
+ Routing: routing,
314
+ WasPreview: true,
315
+ };
316
+ }
317
+ // ── 4. Execute: one transaction, all-or-none ──────────────────────────
318
+ await db.BeginTransaction();
319
+ transactionOpen = true;
320
+ for (const plan of routing) {
321
+ await this.execute(plan, deal, policy, provider, user);
322
+ }
323
+ /**
324
+ * THE FINANCE TASKS A WON DEAL OWES (S-US2 #34, S-US3 #35).
325
+ *
326
+ * INSIDE the transaction, deliberately and on evidence. `TaskOrchestrationService` writes
327
+ * through the global `Metadata.Provider`, which in a server process is the SAME provider this
328
+ * operation opened its transaction on — so the writes join it and a rolled-back close takes
329
+ * the tasks with it. Measured rather than assumed: a probe that created a task inside an
330
+ * ambient transaction and rolled back found the row gone, and the integration suite has
331
+ * created hundreds of task rows inside rolled-back checks without leaving one behind.
332
+ *
333
+ * Its issues are folded in as WARNINGS, not errors. The deal really did close and its
334
+ * provenance is written; an unconfigured task type or a missing finance assignee is a
335
+ * deployment gap to surface, not a reason to refuse a close that has already succeeded.
336
+ * `Section: 'deal'` because the issue vocabulary has no tasks section — worth adding when
337
+ * the surface exists to badge it.
338
+ */
339
+ const taskIssues = [];
340
+ /**
341
+ * The stage this close lands in, resolved BEFORE the stamp because `stampClose` is
342
+ * synchronous and this needs two reads. `input.ClosingStageID` wins when supplied.
343
+ */
344
+ const derivedClosingStageID = input.ClosingStageID
345
+ ? null
346
+ : await this.closingStageForOutcome(deal.PipelineID, target, provider, user);
347
+ this.stampClose(deal, input, target, routing, user, derivedClosingStageID);
348
+ if (!(await deal.Save())) {
349
+ throw new Error(`the deal could not be saved: ${deal.LatestResult?.CompleteMessage ?? 'unknown error'}`);
350
+ }
351
+ // The save appended the close's `DealStageEvent` — one row, whether or not the stage moved.
352
+ // Read AFTER it for the same reason `OrderID` is: the row does not exist until then.
353
+ const stageEvent = deal.LastStageEventID;
354
+ /**
355
+ * ── THE TASKS RUN **AFTER** THE SAVE, AND THAT IS THE FIX RATHER THAN A TIDY-UP ──────────
356
+ *
357
+ * This block used to sit twenty lines earlier, before `deal.Save()`, and it read
358
+ * `deal.OrderID`. That was correct until provisioning MOVED INTO `Save()` this round. After
359
+ * that move, closing any deal that did not already carry an order — every seeded, legacy and
360
+ * imported deal — read an EMPTY OrderID, so the service reported
361
+ *
362
+ * "The deal has no order, so no order-review task was created…"
363
+ *
364
+ * as a warning on a SUCCESSFUL close, raised no order-review task, and then `Save()` created
365
+ * the order a moment later. Finance got nothing, and the warning stated the opposite of what
366
+ * had just happened. Two rules that were each right on their own, in the wrong order.
367
+ *
368
+ * Reading after the save is the smaller of the two available fixes: provisioning explicitly
369
+ * beforehand would put a second caller in the business of knowing when an order is due, which
370
+ * is exactly what moving it into `Save()` was for.
371
+ *
372
+ * STILL INSIDE THE TRANSACTION. `CommitTransaction` is below, so the tasks remain
373
+ * all-or-none with the close — `close-won-tasks.WT9` is the check that pins that.
374
+ *
375
+ * ── AND `ContractID` IS PASSED, WHICH IT NEVER WAS ──────────────────────────────────────
376
+ *
377
+ * `executeRoute` sets `deal.ContractID` in the routing loop that runs before this, so the
378
+ * value has been available all along and the input literal simply omitted it. The service's
379
+ * `if (input.ContractID)` branch was therefore UNREACHABLE from production, and its
380
+ * "contracts is not installed" fallback was dead code. Every contract-processing task linked
381
+ * the DEAL instead of the contract it exists to process, and nothing reported it, because the
382
+ * branch that would have reported it is the one that never ran.
383
+ */
384
+ if (target.IsWon) {
385
+ const cfg = ReadCloseWonTaskConfig(policy);
386
+ const taskResult = await new CloseWonTaskService().CreateCloseWonTasks({
387
+ DealID: deal.ID,
388
+ /**
389
+ * The NAME, so the finance queue reads as deals rather than as hex. The service
390
+ * used to interpolate the id into the task name; the id is still reachable, because
391
+ * both tasks now carry a link to the deal itself.
392
+ *
393
+ * Falls back to the deal number and then to the id: a task named after nothing is
394
+ * worse than one named after a key, and `Name` is nullable on the entity.
395
+ */
396
+ DealName: deal.Name?.trim() || deal.DealNumber || deal.ID,
397
+ // Read AFTER the save: provisioning happens inside it. See the note above.
398
+ OrderID: String(deal.OrderID ?? ''),
399
+ PipelineID: deal.PipelineID,
400
+ // Set by the routing loop above. Omitting it is what made the contract task link
401
+ // the deal, and made the service's own fallback message unreachable.
402
+ ContractID: deal.ContractID ?? undefined,
403
+ /**
404
+ * ── A DUE DATE, WHICH THESE TASKS HAVE NEVER HAD ────────────────────────
405
+ *
406
+ * `CloseWonTaskService` accepts `DueAt` and passes it straight to tasks'
407
+ * `CreateTask`; this operation simply never supplied one. So every task the app
408
+ * has ever raised carried a NULL due date, and everything downstream that reads
409
+ * one was inert for our tasks: `Task.DueAt`, `IsOverdue`, the Overdue KPI and the
410
+ * `OnOverdue` hook all already exist in tasks and all had nothing to act on.
411
+ *
412
+ * Nothing to build there — only to populate. Dated from the close itself rather
413
+ * than from `new Date()` so the task's clock and the deal's provenance agree, and
414
+ * so a close replayed inside a transaction is deterministic.
415
+ */
416
+ DueAt: CloseWonTaskDueAt(deal.ClosedAt ?? new Date(), cfg.DueInDays),
417
+ // No task-type IDs: the service resolves both by Code from rows this repo
418
+ // seeds. What the policy still carries is the part that varies — the assignee.
419
+ Assignee: cfg.AssigneeRecordID
420
+ ? {
421
+ EntityName: cfg.AssigneeEntityName ?? 'MJ_BizApps_Common: People',
422
+ RecordID: cfg.AssigneeRecordID,
423
+ RoleID: cfg.AssigneeRoleID ?? undefined,
424
+ }
425
+ : undefined,
426
+ }, provider, user);
427
+ for (const message of taskResult.Issues) {
428
+ taskIssues.push({ Section: 'deal', Field: null, Severity: 'warning', Message: message });
429
+ }
430
+ }
431
+ await db.CommitTransaction();
432
+ transactionOpen = false;
433
+ return {
434
+ Success: true,
435
+ /**
436
+ * TWO SOURCES OF NON-FATAL WARNINGS, ONE FIELD, AND A STATED ORDER.
437
+ *
438
+ * Both arrived independently: the close creates finance's tasks (S-US2/S-US3) and can
439
+ * also move the deal into a CLOSING stage whose `OrderStatusOnEntry` names an order
440
+ * status — `Voided` on a lost stage (S-US7). Either can decline without failing the
441
+ * close: a task type that will not resolve, or orders refusing `Voided -> Quoted` on a
442
+ * reopened lost deal, which S-US8 says WILL happen.
443
+ *
444
+ * Each side of the merge assigned this field alone, so taking either textually would
445
+ * have SILENTLY DROPPED the other's warnings — a close that quietly did half its
446
+ * downstream work and reported success. Concatenated deliberately instead, in the order
447
+ * the work actually happened: the tasks are created above, and the order status is
448
+ * written inside `deal.Save()` a few lines further down. So a reader of the array reads
449
+ * the close in sequence, which is the only ordering that means anything here.
450
+ */
451
+ Issues: [...taskIssues, ...orderStatusIssues(deal), ...routingIssues(routing, false)],
452
+ IsWon: target.IsWon,
453
+ IsLost: target.IsLost,
454
+ Locked: target.LocksDeal,
455
+ EffectivePolicy: policy,
456
+ Routing: routing,
457
+ DealStageEventID: stageEvent,
458
+ ClosedAt: deal.ClosedAt ? String(deal.ClosedAt) : null,
459
+ WasPreview: false,
460
+ };
461
+ }
462
+ catch (err) {
463
+ if (transactionOpen) {
464
+ try {
465
+ await provider.RollbackTransaction();
466
+ }
467
+ catch (rollbackErr) {
468
+ LogError(`Sales.CloseDeal: rollback failed: ${rollbackErr}`);
469
+ }
470
+ }
471
+ LogError(`Sales.CloseDeal failed: ${err}`);
472
+ return { ...empty, Issues: [issue('deal', `The deal could not be closed: ${err}`)] };
473
+ }
474
+ }
475
+ /* ── Validation (§7.2 step 1–2) ─────────────────────────────────────────── */
476
+ /**
477
+ * What this close requires before it may proceed.
478
+ *
479
+ * The loss-reason rule is the app's ONLY mandatory field and the friction is deliberate: loss
480
+ * reasons are the highest-value and most consistently-skipped data in any CRM (§7.2).
481
+ */
482
+ async validate(deal, input, target, provider, user) {
483
+ const issues = [];
484
+ if (target.IsLost) {
485
+ const lossReasonID = input.LossReasonID ?? deal.LossReasonID;
486
+ if (!lossReasonID) {
487
+ issues.push(issue('deal', 'A loss reason is required to close a deal as lost.', 'LossReasonID'));
488
+ }
489
+ else {
490
+ // Some reasons demand an explanation — the flag says which, never the reason's name.
491
+ const view = provider;
492
+ const r = await view.RunView({
493
+ EntityName: LOSS_REASON_ENTITY,
494
+ ExtraFilter: `ID = '${lossReasonID}'`,
495
+ ResultType: 'simple',
496
+ Fields: ['RequiresNotes'],
497
+ }, user);
498
+ const row = (r.Results ?? [])[0];
499
+ const notes = input.LossNotes ?? deal.LossNotes;
500
+ if (r.Success && row?.RequiresNotes === true && !notes?.trim()) {
501
+ issues.push(issue('deal', 'This loss reason requires notes explaining the loss.', 'LossNotes'));
502
+ }
503
+ }
504
+ }
505
+ if (target.IsWon && !deal.AccountID) {
506
+ issues.push(issue('party', 'A won deal must be attached to a customer.', 'AccountID'));
507
+ }
508
+ return issues;
509
+ }
510
+ /* ── Policy (§7.1) ──────────────────────────────────────────────────────── */
511
+ /**
512
+ * Pipeline default, with the caller's overrides merged over it (§7.1, D-CF6).
513
+ *
514
+ * A malformed policy is a WARNING-shaped failure that falls back to defaults rather than throwing:
515
+ * a pipeline with unparseable JSON should not make its deals uncloseable, and the effective policy
516
+ * is returned to the caller so the fallback is visible rather than silent.
517
+ */
518
+ async resolvePolicy(deal, input, provider, user) {
519
+ let fromPipeline = {};
520
+ const view = provider;
521
+ const r = await view.RunView({
522
+ EntityName: PIPELINE_ENTITY,
523
+ ExtraFilter: `ID = '${deal.PipelineID}'`,
524
+ ResultType: 'simple',
525
+ Fields: ['CloseWonPolicy'],
526
+ }, user);
527
+ const raw = (r.Results ?? [])[0]?.CloseWonPolicy;
528
+ if (r.Success && raw) {
529
+ try {
530
+ fromPipeline = JSON.parse(raw);
531
+ }
532
+ catch {
533
+ LogError(`Sales.CloseDeal: pipeline ${deal.PipelineID} has unparseable CloseWonPolicy; using defaults`);
534
+ }
535
+ }
536
+ return { ...POLICY_DEFAULTS, ...fromPipeline, ...(input.PolicyOverrides ?? {}) };
537
+ }
538
+ /* ── Routing (§7.2 steps 4–5) ───────────────────────────────────────────── */
539
+ /**
540
+ * What the policy says should happen, before anything is attempted.
541
+ *
542
+ * ── THERE IS NO LONGER AN ORDER ROUTE, AND THAT IS THE POINT ──
543
+ *
544
+ * Close-won used to create the order. It no longer does: the order is an EMBEDDED RECORD created
545
+ * with the deal and carrying its lines from the start (S-US4), so by the time a deal closes the
546
+ * order already exists. S-US5 is explicit that Closed Won leaves its status ALONE and editable, so
547
+ * finance can review and correct before the Confirm that locks it and triggers invoicing.
548
+ *
549
+ * So close-won no longer counts lines at all — there are none on the deal to count. What it plans
550
+ * is the CONTRACT (B2B only), and the still-unbuilt Subscription materialization.
551
+ */
552
+ async planRouting(deal, policy, provider, user) {
553
+ const plans = [];
554
+ if (policy.CreateContract === true) {
555
+ // LineCount 0, and not a placeholder: S-US2's contract is a header — customer, company,
556
+ // contact, type defaults, template, number, the linked pair and TemplateModified. It has no
557
+ // line items, which is the same consolidation that retired DealLine.
558
+ plans.push({ Target: 'Contract', Planned: true, Executed: false, LineCount: 0 });
559
+ }
560
+ if (policy.SubscriptionLinesTo === 'Subscription') {
561
+ plans.push({
562
+ Target: 'Subscription',
563
+ Planned: true,
564
+ Executed: false,
565
+ LineCount: 0,
566
+ Reason: 'Subscription materialization needs orders\' Subscription.BillingMode (C0), which does ' +
567
+ 'not exist yet.',
568
+ });
569
+ }
570
+ return plans;
571
+ }
572
+ /** Attempt one planned route through the seam, recording honestly whether it executed. */
573
+ async execute(plan, deal, policy, provider, user) {
574
+ const seam = resolveSeam(user, provider);
575
+ if (plan.Target === 'Contract') {
576
+ // A renewal takes a different door: RenewTerm against the contract being renewed, not a
577
+ // new agreement. Decided by DealType.RequiresRenewalSource — a flag, not a type name.
578
+ const isRenewal = await this.dealTypeRequiresRenewalSource(deal.DealTypeID, provider, user);
579
+ const result = isRenewal
580
+ ? await seam.RenewContractTerm({
581
+ ContractID: deal.RenewsContractID ?? '',
582
+ DealID: deal.ID,
583
+ TermMonths: policy.TermMonths ?? deal.TermMonths ?? null,
584
+ })
585
+ : await seam.CreateContractFromDeal(await this.buildContractInput(deal, policy, provider, user));
586
+ plan.Executed = result.Success === true;
587
+ plan.RecordID = result.ContractID ?? null;
588
+ plan.Reason = result.Success ? null : result.Message ?? 'not executed';
589
+ if (result.Success && result.ContractID) {
590
+ // §7.2 step 4: stamp the agreement onto the deal.
591
+ deal.ContractID = result.ContractID;
592
+ }
593
+ return;
594
+ }
595
+ // Subscription materialization has no seam at all yet; the plan already carries its reason.
596
+ plan.Executed = false;
597
+ }
598
+ /**
599
+ * The stage a close should land in, when the caller did not name one.
600
+ *
601
+ * ── THE MIRROR OF `priorStageFromCloseEvent`, AND THE OTHER HALF OF DN-18 ───────────────────────
602
+ *
603
+ * `CD18` proves a reopen restores the stage the deal came from. It could not help a deal closed
604
+ * through the browser, because `DealWorkspaceComponent.ConfirmClose()` sends no `ClosingStageID` — so
605
+ * no browser-driven close ever moved the stage, every close event recorded
606
+ * `FromStageID === ToStageID`, and the reopen correctly derived the stage the deal was already in.
607
+ * The mechanism was right and the UI could not reach it. `71-lost-and-reopen` is the spec that names
608
+ * this.
609
+ *
610
+ * Fixed in the OPERATION rather than in `ConfirmClose`, for the fourth time and the same reason: an
611
+ * agent, an importer and an API caller all close deals too, and a rule the app owns belongs on the
612
+ * write path. `input.ClosingStageID` stays an override.
613
+ *
614
+ * ── CHOSEN BY FLAG, WHICH IS THE ONLY WAY THIS CAN WORK ────────────────────────────────────────
615
+ *
616
+ * The stage on the deal's OWN pipeline whose declared `DealStatusType` matches the outcome being
617
+ * recorded — `IsWon` for a won close, `IsLost` for a lost one. Never a name: a pipeline is free to
618
+ * call its winning stage "Signed", "Booked" or anything else, and §3 is what makes that a label.
619
+ *
620
+ * Returns null rather than guessing when the pipeline declares no such stage. A deal on a pipeline
621
+ * with no losing stage closes as lost without moving, exactly as it did before this existed — the
622
+ * close is never blocked by the absence of somewhere to put it.
623
+ *
624
+ * ── AND IT LEANS ON TWO GUARDS THAT ARE NOW LOAD-BEARING ───────────────────────────────────────
625
+ *
626
+ * Moving into a stage that declares a LOCKING status is only safe because `applyStageDefaults` refuses
627
+ * to derive a locking status (`SD35`), so the stage cannot close the deal a second time; and the
628
+ * closing status stays authoritative because a declared transition suppresses stage defaults entirely
629
+ * (`CD17`). Both are mutant-proven. Without them this change would double-close every deal it touched.
630
+ */
631
+ async closingStageForOutcome(pipelineID, target, provider, user) {
632
+ if (!pipelineID || (!target.IsWon && !target.IsLost)) {
633
+ return null; // an outcome that is neither won nor lost names no stage
634
+ }
635
+ const view = provider;
636
+ const statuses = await view.RunView({
637
+ EntityName: DEAL_STATUS_ENTITY,
638
+ ExtraFilter: target.IsWon ? 'IsWon = 1' : 'IsLost = 1',
639
+ ResultType: 'simple',
640
+ Fields: ['ID'],
641
+ }, user);
642
+ if (!statuses.Success) {
643
+ return null;
644
+ }
645
+ const ids = (statuses.Results ?? []).map((r) => String(r.ID)).filter(Boolean);
646
+ if (ids.length === 0) {
647
+ return null;
648
+ }
649
+ const quoted = ids.map((id) => `'${id}'`).join(', ');
650
+ const stages = await view.RunView({
651
+ EntityName: STAGE_ENTITY,
652
+ ExtraFilter: `PipelineID = '${pipelineID}' AND IsActive = 1 AND DealStatusTypeID IN (${quoted})`,
653
+ OrderBy: 'DisplayOrder ASC',
654
+ ResultType: 'simple',
655
+ Fields: ['ID'],
656
+ }, user);
657
+ if (!stages.Success) {
658
+ return null;
659
+ }
660
+ const row = (stages.Results ?? [])[0];
661
+ return row?.ID ? String(row.ID) : null;
662
+ }
663
+ /* ── The close stamp (§7.2 step 6) ──────────────────────────────────────── */
664
+ /**
665
+ * Append the provenance row and stamp the deal.
666
+ *
667
+ * `DealStageEvent` is APPEND-ONLY and stamps `AmountAtTransition` / `ProbabilityAtTransition`,
668
+ * because "what did we think this was worth when we closed it" is unanswerable later once amounts
669
+ * change. The routing outcome goes into `Notes` — which is what preserves the intent of a close
670
+ * whose downstream was stubbed (D-CF1).
671
+ *
672
+ * §7 also calls for writing an `Activity` here. The Activity spine does not exist in common or MJ
673
+ * core, so that step is skipped and recorded in CLOSE-FLOW-DECISIONS.md D-CF5 rather than faked.
674
+ */
675
+ stampClose(deal, input, target, routing, user, derivedClosingStageID) {
676
+ /**
677
+ * ── DECLARED, NOT WRITTEN. THIS METHOD USED TO APPEND THE ROW ITSELF ────────────────────────
678
+ *
679
+ * It built a `DealStageEvent` here and then set `deal.PipelineStageID` below — so `Save()` saw a
680
+ * stage change and appended a SECOND row for the same close. Every close that passed a
681
+ * `ClosingStageID` doubled its own provenance, and `CD4` was blind to it because it never passes
682
+ * one.
683
+ *
684
+ * `DeclareTransition` hands the entity the one thing it could not derive — the routing note — and
685
+ * leaves the writing to the single writer, which also suppresses the stage defaults so a close's
686
+ * probability is not re-derived from the stage it lands in. The `From`/`To` stamps come out the
687
+ * same, because the entity reads them from `OldValue`, which is what this method read too.
688
+ *
689
+ * The event id is no longer available here: it exists only once the save has appended the row.
690
+ * The caller reads `deal.LastStageEventID` afterwards.
691
+ */
692
+ deal.DeclareTransition('Close', this.routingNote(routing, input.Notes));
693
+ // Everything stored is UTC — getUTC*, never local-time getters, for anything persisted.
694
+ const now = new Date();
695
+ deal.DealStatusTypeID = target.ID;
696
+ // Derived when the caller named no stage -- see closingStageForOutcome. This is what gives a
697
+ // browser-driven close a stage to move into, and therefore gives the reopen something to restore.
698
+ const closingStage = input.ClosingStageID ?? derivedClosingStageID;
699
+ if (closingStage) {
700
+ deal.PipelineStageID = closingStage;
701
+ }
702
+ if (input.LossReasonID) {
703
+ deal.LossReasonID = input.LossReasonID;
704
+ }
705
+ if (input.LossNotes) {
706
+ deal.LossNotes = input.LossNotes;
707
+ }
708
+ deal.ClosedAt = now;
709
+ deal.ClosedByUserID = user.ID;
710
+ // ActualCloseDate is a DATE, not a datetime: truncate at UTC midnight so a deal closed at
711
+ // 23:00 UTC does not land in tomorrow's period for a reader in another timezone.
712
+ deal.ActualCloseDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
713
+ }
714
+ /** A human-readable record of what the policy routed, and what actually happened. */
715
+ routingNote(routing, callerNote) {
716
+ const parts = [];
717
+ if (callerNote?.trim()) {
718
+ parts.push(callerNote.trim());
719
+ }
720
+ if (routing.length === 0) {
721
+ parts.push('Closed. Policy routed nothing downstream.');
722
+ }
723
+ else {
724
+ for (const r of routing) {
725
+ parts.push(r.Executed
726
+ ? `${r.Target}: created ${r.RecordID ?? '(no id)'} (${r.LineCount} line(s)).`
727
+ : `${r.Target}: PLANNED but not executed (${r.LineCount} line(s)) — ${r.Reason ?? 'no reason given'}`);
728
+ }
729
+ }
730
+ return parts.join(' ');
731
+ }
732
+ /* ── Helpers ────────────────────────────────────────────────────────────── */
733
+ async loadStatusFlags(statusID, provider, user) {
734
+ const view = provider;
735
+ const r = await view.RunView({
736
+ EntityName: DEAL_STATUS_ENTITY,
737
+ ExtraFilter: `ID = '${statusID}'`,
738
+ ResultType: 'simple',
739
+ Fields: ['ID', 'IsWon', 'IsLost', 'IsClosed', 'LocksDeal'],
740
+ }, user);
741
+ if (!r.Success) {
742
+ return null;
743
+ }
744
+ const row = (r.Results ?? [])[0];
745
+ if (!row) {
746
+ return null;
747
+ }
748
+ return {
749
+ ID: String(row.ID),
750
+ IsWon: row.IsWon === true,
751
+ IsLost: row.IsLost === true,
752
+ IsClosed: row.IsClosed === true,
753
+ LocksDeal: row.LocksDeal === true,
754
+ };
755
+ }
756
+ /** Split by the type's `IsRecurring` FLAG. A join, so one read rather than one per line. */
757
+ async dealTypeRequiresRenewalSource(dealTypeID, provider, user) {
758
+ if (!dealTypeID) {
759
+ return false;
760
+ }
761
+ const view = provider;
762
+ const r = await view.RunView({
763
+ EntityName: DEAL_TYPE_ENTITY,
764
+ ExtraFilter: `ID = '${dealTypeID}'`,
765
+ ResultType: 'simple',
766
+ Fields: ['RequiresRenewalSource'],
767
+ }, user);
768
+ const row = (r.Results ?? [])[0];
769
+ return r.Success && row?.RequiresRenewalSource === true;
770
+ }
771
+ async buildContractInput(deal, policy, provider, user) {
772
+ return {
773
+ DealID: deal.ID,
774
+ CompanyID: deal.CompanyID,
775
+ ContractTypeCode: policy.ContractTypeCode ?? null,
776
+ TermMonths: policy.TermMonths ?? deal.TermMonths ?? null,
777
+ // The AD's red-lines travel with the agreement — a contract that silently dropped them
778
+ // would send a human reviewer in blind.
779
+ ContractVariances: deal.ContractVariances,
780
+ // And the flag that says whether there ARE any, which is the thing finance's review keys
781
+ // on. Reported even when the variances field is blank: the two answer different questions.
782
+ StandardAgreementModified: deal.StandardAgreementModified,
783
+ AutoRenew: deal.AutoRenew,
784
+ AnnualIncreasePctOverride: deal.AnnualIncreasePctOverride,
785
+ CancellationNoticeDaysOverride: deal.CancellationNoticeDaysOverride,
786
+ /**
787
+ * STATED, BUT NOT STAMPED ONTO THE CONTRACT — and the split is deliberate.
788
+ *
789
+ * These are facts about the DEAL, so this method reports them faithfully. What the seam
790
+ * does with them is the seam's business, and today it does nothing: v2 derives a
791
+ * contract's lifecycle from its dates, so an `EffectiveDate` on a contract nobody has
792
+ * approved yet would make it read as live and in force. `LiveContractsSeam` records the
793
+ * reasoning in full; the point here is that a reader of this method should not conclude
794
+ * these land on the agreement.
795
+ */
796
+ ExecutionDate: deal.ExecutionDate ? String(deal.ExecutionDate) : null,
797
+ StartDate: deal.StartDate ? String(deal.StartDate) : null,
798
+ // The buying party, so contracts can stamp the customer without looking back at the deal.
799
+ AccountID: deal.AccountID,
800
+ PrimaryContactID: deal.PrimaryContactID,
801
+ /**
802
+ * NO `BillingFrequency`, NO `CommittedAmount`, NO `Lines` — all three now have nowhere to
803
+ * go rather than merely being unset.
804
+ *
805
+ * The 2026-08-18 contracts rebuild deleted `ContractTerm` and `ContractLine` outright: v2's
806
+ * contract IS the header. Billing frequency and the committed amount were TERM columns and
807
+ * line items were the redundancy the rework removed, so the earlier note here — that the
808
+ * seam would apply its own billing-frequency default and that contracts' line retirement
809
+ * was "the other half of it, on their side" — describes a schema that no longer exists.
810
+ * Their half landed; this is ours.
811
+ */
812
+ };
813
+ }
814
+ };
815
+ CloseDealOperation = __decorate([
816
+ RegisterClass(BaseRemotableOperation, 'Sales.CloseDeal')
817
+ ], CloseDealOperation);
818
+ export { CloseDealOperation };
819
+ /**
820
+ * `Sales.ReopenDeal` — the only sanctioned exit from the close lock (§7.3).
821
+ *
822
+ * Writes its own `DealStageEvent` first, so the fact that a close HAPPENED survives the close being
823
+ * undone. Provenance is append-only: reopening adds to the record rather than erasing it.
824
+ */
825
+ let ReopenDealOperation = class ReopenDealOperation extends SalesReopenDealOperationBase {
826
+ async InternalExecute(input, provider, user) {
827
+ const empty = { Success: false, Issues: [], Unlocked: false };
828
+ if (!input?.DealID) {
829
+ return { ...empty, Issues: [issue('deal', 'A deal is required.', 'DealID')] };
830
+ }
831
+ // §7.3 makes this mandatory: undoing a lock has to be explainable.
832
+ if (!input.Reason?.trim()) {
833
+ return { ...empty, Issues: [issue('deal', 'A reason is required to reopen a closed deal.', 'Reason')] };
834
+ }
835
+ const db = provider;
836
+ let transactionOpen = false;
837
+ try {
838
+ const deal = await provider.GetEntityObject(DEAL_ENTITY, user);
839
+ if (!(await deal.Load(input.DealID))) {
840
+ return { ...empty, Issues: [issue('deal', `Deal ${input.DealID} was not found.`, 'DealID')] };
841
+ }
842
+ const target = input.DealStatusTypeID
843
+ ? await this.resolveTarget(input.DealStatusTypeID, provider, user)
844
+ : await this.firstOpenStatus(provider, user);
845
+ if (!target) {
846
+ return {
847
+ ...empty,
848
+ Issues: [
849
+ issue('deal', 'No open status is available to reopen into. Seed a DealStatusType with IsOpen = 1.', 'DealStatusTypeID'),
850
+ ],
851
+ };
852
+ }
853
+ if (target.LocksDeal) {
854
+ return {
855
+ ...empty,
856
+ Issues: [issue('deal', 'Cannot reopen into a status that locks the deal.', 'DealStatusTypeID')],
857
+ };
858
+ }
859
+ /**
860
+ * ── DN-20: A BOOKED ORDER REFUSES THE REOPEN OUTRIGHT ───────────────────────────────
861
+ *
862
+ * Raised by Finance. Until now a reopen succeeded whatever state the order was in: it
863
+ * restored the prior stage and reported what the order refused. That is not enough once the
864
+ * order has BOOKED, because the ledger has moved -- the correct instrument is then an
865
+ * order-side one, and editing the deal behind a live receivable is not it.
866
+ *
867
+ * ── WHY `IsBooked` AND NOT THE TWO OBVIOUS TESTS ────────────────────────────────────
868
+ *
869
+ * Both alternatives are wrong, in opposite directions, and the data on the host showed it:
870
+ *
871
+ * `Status === 'Confirmed'` UNDER-blocks. The lifecycle runs on to `Posted` and then
872
+ * `Fulfilled`, so an equality test lets through precisely the orders whose ledger has
873
+ * moved FURTHEST. It is also a string comparison against orders' vocabulary, which is the
874
+ * shape rule two forbids.
875
+ *
876
+ * `ConfirmedAt IS NOT NULL` OVER-blocks. That column is a timestamp, and an order that was
877
+ * confirmed and later VOIDED keeps it forever. Refusing there would block the one case
878
+ * where the ledger has been SETTLED: orders records a reversal as its own record (D53), so
879
+ * a voided order is not a live receivable and the deal behind it is safe to reopen.
880
+ *
881
+ * `IsBooked` is the only one of the three that means "the ledger currently stands", and it
882
+ * gives the confirmed-then-voided case the right answer for free -- `IsBooked('Voided')` is
883
+ * false. The two directions are the point: a later "simplification" to
884
+ * `status === 'Confirmed'` breaks both, and `close-deal.CD23` exists to catch that.
885
+ *
886
+ * ── WHAT IT SAYS, AND WHAT IT DELIBERATELY DOES NOT ─────────────────────────────────
887
+ *
888
+ * The message states only what sales owns: that it refuses, which order, orders' own verdict,
889
+ * and an explicit handoff. It names NO remedy. The right instrument differs by status -- a
890
+ * `Fulfilled` order is a void plus a return order, a `Confirmed` one is not -- and sales
891
+ * narrating another app's workflow would be worse than an honest handoff. If a named remedy
892
+ * is wanted later, orders exports it and sales quotes it, the way it quotes
893
+ * `CanTransition`'s `Reason` today.
894
+ *
895
+ * A deal with NO order has no peer here and is not refused, which is the correct reading:
896
+ * there is no ledger to have moved.
897
+ */
898
+ /**
899
+ * ── AN UNREADABLE PEER IS REFUSED, NOT WAVED THROUGH ────────────────────────
900
+ *
901
+ * `OrderID_Object` returns silently UNEXPOSED when the peer did not load —
902
+ * `EmbeddedRecord.LoadEager` gates `Value` behind a private flag and does not throw. So
903
+ * `order` was undefined, `orderStatus` was null, and the refusal was SKIPPED for exactly the
904
+ * state it should refuse hardest: a deal that points at an order nobody can read. The
905
+ * booked-order guard silently became "no order, carry on".
906
+ *
907
+ * `deal.OrderID` is the discriminator and it is on the deal's own row, so it is readable even
908
+ * when the peer is not. FK set + peer unresolved is not "no order" — it is "unknown
909
+ * order", and an unknown ledger state cannot be certified safe to reopen.
910
+ *
911
+ * THE SIBLING CALL SITE ALREADY DECIDED THIS. `DealWorkspaceService.LoadDeal` treats the same
912
+ * state as a hard failure and refuses to render, on the reasoning that a deal with an
913
+ * unreadable order must not be shown as a deal with no lines. Two files reading one state and
914
+ * returning opposite verdicts is the defect; this makes them agree.
915
+ */
916
+ if (deal.OrderID && !deal.OrderID_Object) {
917
+ return {
918
+ ...empty,
919
+ Issues: [
920
+ issue('deal', `This deal cannot be reopened: it points at order ${deal.OrderID}, which could `
921
+ + `not be read, so whether that order has booked is unknown. Reopening on an `
922
+ + `unknown ledger state is the one thing this refusal exists to prevent. `
923
+ + `Try again, and if it persists the order is the thing to look at.`, 'DealID'),
924
+ ],
925
+ };
926
+ }
927
+ const order = deal.OrderID_Object;
928
+ const orderStatus = order?.Status ?? null;
929
+ if (orderStatus && IsBooked(orderStatus)) {
930
+ return {
931
+ ...empty,
932
+ Issues: [
933
+ issue('deal', `This deal cannot be reopened: order ${order?.OrderNumber ?? '(unnumbered)'} has `
934
+ + `booked, so the ledger has already moved. Reopening would edit a deal whose `
935
+ + `order can no longer be edited. What to do about the order is an orders `
936
+ + `decision — resolve it there first, then reopen this deal.`, 'DealID'),
937
+ ],
938
+ };
939
+ }
940
+ await db.BeginTransaction();
941
+ transactionOpen = true;
942
+ /**
943
+ * ── SAME MECHANISM AS THE CLOSE, AND FOR A THIRD REASON BESIDES THE DUPLICATE ROW ────────
944
+ *
945
+ * This hand-wrote its event and then moved the stage, so a reopen that named a `StageID` got
946
+ * two rows exactly as the close did. It also invoked the stage-DEFAULTS writer without meaning
947
+ * to: the save re-derived `Probability` from the arriving stage, AFTER the event above had
948
+ * stamped the value being left behind. The reopen's own provenance row disagreed with the deal
949
+ * it described, and nothing was comparing the two.
950
+ *
951
+ * A declared transition answers both — one row, and the defaults writer stands down because
952
+ * the probability on a reopened deal is a human's judgement, not the pipeline's default.
953
+ */
954
+ deal.DeclareTransition('Reopen', `REOPENED: ${input.Reason.trim()}`);
955
+ deal.DealStatusTypeID = target.ID;
956
+ /**
957
+ * THE PRIOR STAGE, DERIVED WHEN THE CALLER DID NOT NAME ONE — DN-18.
958
+ *
959
+ * `input.StageID` stays an override: a caller who knows where the deal should land says so.
960
+ * Absent that, the close event says where it came from, and restoring it is what makes
961
+ * `OrderStatusOnEntry` fire so the order is asked to come back. A status-only close left the
962
+ * stage where it was, so this resolves to the stage the deal is already in and nothing fires.
963
+ */
964
+ const landingStage = input.StageID ?? (await this.priorStageFromCloseEvent(deal.ID, provider, user));
965
+ if (landingStage) {
966
+ deal.PipelineStageID = landingStage;
967
+ }
968
+ // The close stamps describe a close that is no longer in effect; clearing them keeps every
969
+ // rollup that tests `ClosedAt IS NOT NULL` honest. The event above preserves the history
970
+ // (CLOSE-FLOW-DECISIONS.md D-CF8).
971
+ deal.ClosedAt = null;
972
+ deal.ClosedByUserID = null;
973
+ deal.ActualCloseDate = null;
974
+ // The save has to write the very row the lock protects, so it runs with the lock suspended
975
+ // — scoped to this call and self-restoring.
976
+ const saved = await deal.BeginReopen(() => deal.Save());
977
+ if (!saved) {
978
+ throw new Error(`the deal could not be saved: ${deal.LatestResult?.CompleteMessage ?? 'unknown error'}`);
979
+ }
980
+ await db.CommitTransaction();
981
+ transactionOpen = false;
982
+ // The reopen SUCCEEDED even when the order refused to come back with it. That is the
983
+ // designed outcome, not a tolerated one: S-US8's reopen enters a stage asking for `Quoted`
984
+ // while the order sits at `Voided`, which orders treats as terminal. The deal reopens and
985
+ // says what did not happen.
986
+ return {
987
+ Success: true,
988
+ Issues: orderStatusIssues(deal),
989
+ Unlocked: true,
990
+ // Appended by the save under the declaration above, so read from the deal afterwards.
991
+ DealStageEventID: deal.LastStageEventID,
992
+ };
993
+ }
994
+ catch (err) {
995
+ if (transactionOpen) {
996
+ try {
997
+ await provider.RollbackTransaction();
998
+ }
999
+ catch (rollbackErr) {
1000
+ LogError(`Sales.ReopenDeal: rollback failed: ${rollbackErr}`);
1001
+ }
1002
+ }
1003
+ LogError(`Sales.ReopenDeal failed: ${err}`);
1004
+ return { ...empty, Issues: [issue('deal', `The deal could not be reopened: ${err}`)] };
1005
+ }
1006
+ }
1007
+ async resolveTarget(statusID, provider, user) {
1008
+ const view = provider;
1009
+ const r = await view.RunView({
1010
+ EntityName: DEAL_STATUS_ENTITY,
1011
+ ExtraFilter: `ID = '${statusID}'`,
1012
+ ResultType: 'simple',
1013
+ Fields: ['ID', 'LocksDeal'],
1014
+ }, user);
1015
+ const row = (r.Results ?? [])[0];
1016
+ return r.Success && row ? { ID: String(row.ID), LocksDeal: row.LocksDeal === true } : null;
1017
+ }
1018
+ /**
1019
+ * The stage a deal was in before it was closed, from the append-only log.
1020
+ *
1021
+ * ── WHY THE OPERATION DERIVES THIS AND THE WORKSPACE DOES NOT SUPPLY IT ─────────────────────────
1022
+ *
1023
+ * DN-18. The story says a reopened deal returns to its prior stage; `DealStageEvent.FromStageID`
1024
+ * records exactly that, and `input.StageID` already existed. The only missing piece was that nobody
1025
+ * sent it — so nothing ever re-entered a stage, `OrderStatusOnEntry` never fired, and a reopened deal
1026
+ * sat pointing at a `Voided` order with nothing on screen saying so.
1027
+ *
1028
+ * Deriving it HERE rather than in the workspace keeps `input.StageID` an override instead of a
1029
+ * requirement, and means an agent, an importer and an API caller get the same restoration a rep does.
1030
+ * That is the same write-path principle as the deal number, the company stamp, the owner stamp and the
1031
+ * embedded order: if the rule is the app's, the server owns it.
1032
+ *
1033
+ * ── WHICH EVENT, AND BY FLAG RATHER THAN BY NAME ────────────────────────────────────────────────
1034
+ *
1035
+ * The most recent event whose `ToDealStatusTypeID` carries `LocksDeal` — the closing transition. Read
1036
+ * as a set of status ids first because `RunView` cannot join, and selected by the flag so a pipeline
1037
+ * calling its winning stage "Signed" and its status "Booked" is unaffected (§3).
1038
+ *
1039
+ * A deal closed more than once has several such events; `ChangedAt DESC` takes the last one, which is
1040
+ * the close being undone.
1041
+ *
1042
+ * ── THE TWO CASES THAT MAKE THIS CORRECT RATHER THAN CONVENIENT ─────────────────────────────────
1043
+ *
1044
+ * · **A stage-moving close** stamped `FromStageID` = the stage before the close and `ToStageID` = the
1045
+ * closing stage. Restoring `FromStageID` moves the stage back, so the order-status writer fires
1046
+ * through the existing mechanism and the order follows — or refuses, and says so.
1047
+ * · **A status-only close** (no `ClosingStageID`) never moved the stage, so the event holds
1048
+ * `FromStageID === ToStageID`. Restoring it assigns the stage the deal is already in, the writer's
1049
+ * own comparison finds no move, and NOTHING fires. There is nothing to restore and nothing
1050
+ * happens — which is the behaviour that has to hold for this to be safe, and it holds without a
1051
+ * special case.
1052
+ *
1053
+ * Returns null when there is no close event to read — a deal closed by SQL, an import that wrote no
1054
+ * provenance, or a log that was never written. The reopen then leaves the stage alone rather than
1055
+ * guessing, which is the same answer as "the caller sent no StageID" and needs no extra branch.
1056
+ */
1057
+ async priorStageFromCloseEvent(dealID, provider, user) {
1058
+ const view = provider;
1059
+ const locking = await view.RunView({
1060
+ EntityName: DEAL_STATUS_ENTITY,
1061
+ ExtraFilter: 'LocksDeal = 1',
1062
+ ResultType: 'simple',
1063
+ Fields: ['ID'],
1064
+ }, user);
1065
+ if (!locking.Success) {
1066
+ return null;
1067
+ }
1068
+ const ids = (locking.Results ?? [])
1069
+ .map((r) => String(r.ID))
1070
+ .filter(Boolean);
1071
+ if (ids.length === 0) {
1072
+ return null; // nothing in this host's vocabulary locks a deal, so nothing closed it
1073
+ }
1074
+ const quoted = ids.map((id) => `'${id}'`).join(', ');
1075
+ const events = await view.RunView({
1076
+ EntityName: STAGE_EVENT_ENTITY,
1077
+ ExtraFilter: `DealID = '${dealID}' AND ToDealStatusTypeID IN (${quoted})`,
1078
+ OrderBy: 'ChangedAt DESC',
1079
+ ResultType: 'simple',
1080
+ Fields: ['FromStageID', 'ChangedAt'],
1081
+ }, user);
1082
+ if (!events.Success) {
1083
+ return null;
1084
+ }
1085
+ const row = (events.Results ?? [])[0];
1086
+ const from = row?.FromStageID;
1087
+ return from ? String(from) : null;
1088
+ }
1089
+ /** The default landing status — chosen by the `IsOpen` FLAG, never by a status name. */
1090
+ async firstOpenStatus(provider, user) {
1091
+ const view = provider;
1092
+ const r = await view.RunView({
1093
+ EntityName: DEAL_STATUS_ENTITY,
1094
+ ExtraFilter: 'IsOpen = 1 AND IsActive = 1',
1095
+ OrderBy: 'DisplayRank ASC',
1096
+ ResultType: 'simple',
1097
+ Fields: ['ID', 'LocksDeal'],
1098
+ }, user);
1099
+ const row = (r.Results ?? [])[0];
1100
+ return r.Success && row ? { ID: String(row.ID), LocksDeal: row.LocksDeal === true } : null;
1101
+ }
1102
+ };
1103
+ ReopenDealOperation = __decorate([
1104
+ RegisterClass(BaseRemotableOperation, 'Sales.ReopenDeal')
1105
+ ], ReopenDealOperation);
1106
+ export { ReopenDealOperation };
1107
+ //# sourceMappingURL=CloseDealOperation.js.map