@mj-biz-apps/sales-entities 6.4.0 → 6.6.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.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @fileoverview Whether `Deal.Amount` still describes the order it was cached from.
3
+ *
4
+ * `Deal.Amount` is a CACHED ANSWER from Orders, stamped with `AmountIsComputed` / `AmountComputedAt` /
5
+ * `AmountSourceHash`. Sales never recomputes it. The only question a sales surface may ask is whether
6
+ * the cached number still matches the order — and that is a COMPARISON of two stored figures, not
7
+ * arithmetic. Nothing here multiplies, discounts, prorates, sums or rounds.
8
+ *
9
+ * ── WHY THIS IS NOT A TIMESTAMP COMPARE ANY MORE (bc-aidp-next-golive#230) ──────────────────────
10
+ *
11
+ * Both surfaces used to read the newest `__mj_UpdatedAt` across the order's lines and call the amount
12
+ * stale if it was later than `AmountComputedAt`. That asks "was a line TOUCHED", and the answer to that
13
+ * is yes for reasons that have nothing to do with the price: closing a deal books the order, which moves
14
+ * the lines' status and stamps `__mj_UpdatedAt` without a figure changing. So every Won deal warned,
15
+ * permanently, that its frozen amount needed repricing.
16
+ *
17
+ * It is the failure shape CLAUDE.md rule 8 names: a claim that was true when it was written -- a touched
18
+ * line usually DID mean a moved price -- and stayed asserted after the close flow started touching lines
19
+ * for its own reasons. The guard was keyed on a PROXY (the timestamp) rather than on the thing it
20
+ * actually cared about (the number). A proxy can be outgrown; the number cannot.
21
+ *
22
+ * So the test is now the one the question deserves: does the cached amount equal the order's current
23
+ * total? That is also EXACTLY the test `DealEntityServer.refreshAmountFromOrder()` uses to decide the
24
+ * cache is already current, so this surface and the server agree by construction rather than by
25
+ * coincidence — if the server would rewrite the cache, this says stale; if it would no-op, this says
26
+ * fresh.
27
+ *
28
+ * @module @mj-biz-apps/sales-entities
29
+ */
30
+ import { type UserInfo } from '@memberjunction/core';
31
+ /**
32
+ * The notice a surface shows when the cached amount no longer matches its order.
33
+ *
34
+ * golive#230: the previous wording — "A line has changed since this amount was last priced. Reprice the
35
+ * order to update the total." — named an action that DOES NOT EXIST. There is no reprice control
36
+ * anywhere in this codebase, so the sentence asked the reader to do something impossible.
37
+ *
38
+ * Saving the deal genuinely is the fix, and that is verifiable rather than hopeful:
39
+ * `DealEntityServer.Save()` sets `amountMayHaveMoved` when `AmountIsComputed === true`, which is
40
+ * precisely the state this notice appears in, and then re-reads `OrderHeader.TotalGross` into the cache.
41
+ */
42
+ export declare const DEAL_AMOUNT_STALE_NOTICE = "The products on this deal changed after the amount was calculated. Save the deal to update it.";
43
+ /** The answer, plus the notice to show. `Notice` is null whenever `IsStale` is false. */
44
+ export interface DealAmountFreshness {
45
+ IsStale: boolean;
46
+ Notice: string | null;
47
+ }
48
+ /** What the caller must already know about the deal. Both surfaces hold all four. */
49
+ export interface DealAmountFreshnessInput {
50
+ /**
51
+ * Whether the PERSISTED status locks the deal, as `ResolveDealLockState` reports it.
52
+ *
53
+ * Required rather than optional on purpose. A caller that forgot to pass it would get the old
54
+ * always-warns behaviour back, silently, on exactly the screen the bug was reported against.
55
+ */
56
+ IsLocked: boolean;
57
+ AmountIsComputed: boolean | null | undefined;
58
+ Amount: number | string | null | undefined;
59
+ OrderID: string | null | undefined;
60
+ }
61
+ /**
62
+ * Whether the deal's cached amount still matches its order's total.
63
+ *
64
+ * Returns FRESH — never warns — in every case where the question is meaningless: a locked deal (the
65
+ * amount is frozen and no edit could change it), a deal whose amount was typed rather than computed,
66
+ * a deal with no order, and an order with no usable total.
67
+ */
68
+ export declare function ResolveDealAmountFreshness(input: DealAmountFreshnessInput, contextUser?: UserInfo): Promise<DealAmountFreshness>;
69
+ //# sourceMappingURL=amount-freshness.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"amount-freshness.d.ts","sourceRoot":"","sources":["../src/amount-freshness.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,OAAO,EAAW,KAAK,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAI9D;;;;;;;;;;GAUG;AACH,eAAO,MAAM,wBAAwB,mGAC+D,CAAC;AAErG,yFAAyF;AACzF,MAAM,WAAW,mBAAmB;IAChC,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,qFAAqF;AACrF,MAAM,WAAW,wBAAwB;IACrC;;;;;OAKG;IACH,QAAQ,EAAE,OAAO,CAAC;IAClB,gBAAgB,EAAE,OAAO,GAAG,IAAI,GAAG,SAAS,CAAC;IAC7C,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IAC3C,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;CACtC;AAKD;;;;;;GAMG;AACH,wBAAsB,0BAA0B,CAC5C,KAAK,EAAE,wBAAwB,EAC/B,WAAW,CAAC,EAAE,QAAQ,GACvB,OAAO,CAAC,mBAAmB,CAAC,CAsD9B"}
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @fileoverview Whether `Deal.Amount` still describes the order it was cached from.
3
+ *
4
+ * `Deal.Amount` is a CACHED ANSWER from Orders, stamped with `AmountIsComputed` / `AmountComputedAt` /
5
+ * `AmountSourceHash`. Sales never recomputes it. The only question a sales surface may ask is whether
6
+ * the cached number still matches the order — and that is a COMPARISON of two stored figures, not
7
+ * arithmetic. Nothing here multiplies, discounts, prorates, sums or rounds.
8
+ *
9
+ * ── WHY THIS IS NOT A TIMESTAMP COMPARE ANY MORE (bc-aidp-next-golive#230) ──────────────────────
10
+ *
11
+ * Both surfaces used to read the newest `__mj_UpdatedAt` across the order's lines and call the amount
12
+ * stale if it was later than `AmountComputedAt`. That asks "was a line TOUCHED", and the answer to that
13
+ * is yes for reasons that have nothing to do with the price: closing a deal books the order, which moves
14
+ * the lines' status and stamps `__mj_UpdatedAt` without a figure changing. So every Won deal warned,
15
+ * permanently, that its frozen amount needed repricing.
16
+ *
17
+ * It is the failure shape CLAUDE.md rule 8 names: a claim that was true when it was written -- a touched
18
+ * line usually DID mean a moved price -- and stayed asserted after the close flow started touching lines
19
+ * for its own reasons. The guard was keyed on a PROXY (the timestamp) rather than on the thing it
20
+ * actually cared about (the number). A proxy can be outgrown; the number cannot.
21
+ *
22
+ * So the test is now the one the question deserves: does the cached amount equal the order's current
23
+ * total? That is also EXACTLY the test `DealEntityServer.refreshAmountFromOrder()` uses to decide the
24
+ * cache is already current, so this surface and the server agree by construction rather than by
25
+ * coincidence — if the server would rewrite the cache, this says stale; if it would no-op, this says
26
+ * fresh.
27
+ *
28
+ * @module @mj-biz-apps/sales-entities
29
+ */
30
+ import { RunView } from '@memberjunction/core';
31
+ const E_ORDER_HEADER = 'MJ_BizApps_Orders: Order Headers';
32
+ /**
33
+ * The notice a surface shows when the cached amount no longer matches its order.
34
+ *
35
+ * golive#230: the previous wording — "A line has changed since this amount was last priced. Reprice the
36
+ * order to update the total." — named an action that DOES NOT EXIST. There is no reprice control
37
+ * anywhere in this codebase, so the sentence asked the reader to do something impossible.
38
+ *
39
+ * Saving the deal genuinely is the fix, and that is verifiable rather than hopeful:
40
+ * `DealEntityServer.Save()` sets `amountMayHaveMoved` when `AmountIsComputed === true`, which is
41
+ * precisely the state this notice appears in, and then re-reads `OrderHeader.TotalGross` into the cache.
42
+ */
43
+ export const DEAL_AMOUNT_STALE_NOTICE = 'The products on this deal changed after the amount was calculated. Save the deal to update it.';
44
+ const FRESH = Object.freeze({ IsStale: false, Notice: null });
45
+ const STALE = Object.freeze({ IsStale: true, Notice: DEAL_AMOUNT_STALE_NOTICE });
46
+ /**
47
+ * Whether the deal's cached amount still matches its order's total.
48
+ *
49
+ * Returns FRESH — never warns — in every case where the question is meaningless: a locked deal (the
50
+ * amount is frozen and no edit could change it), a deal whose amount was typed rather than computed,
51
+ * a deal with no order, and an order with no usable total.
52
+ */
53
+ export async function ResolveDealAmountFreshness(input, contextUser) {
54
+ /**
55
+ * A CLOSED DEAL NEVER WARNS, and this is the first test rather than one of several.
56
+ *
57
+ * The amount is frozen by the close lock, so there is no edit that could resolve the notice and no
58
+ * reason to read it. golive#230 is precisely this case: the close books the order, the order's
59
+ * lines move, and the deal is simultaneously forbidden from doing anything about it. A warning
60
+ * nobody can act on is worse than silence — it trains the reader to ignore the flag that matters.
61
+ */
62
+ if (input.IsLocked) {
63
+ return FRESH;
64
+ }
65
+ // A typed amount claims nothing about the order, so it cannot disagree with one.
66
+ if (!input.AmountIsComputed || !input.OrderID) {
67
+ return FRESH;
68
+ }
69
+ const result = await new RunView().RunView({
70
+ EntityName: E_ORDER_HEADER,
71
+ ExtraFilter: `ID = '${String(input.OrderID).replace(/'/g, "''")}'`,
72
+ ResultType: 'simple',
73
+ Fields: ['TotalGross'],
74
+ }, contextUser);
75
+ if (!result?.Success) {
76
+ // A failed read is not evidence of staleness. Saying nothing is the honest answer.
77
+ return FRESH;
78
+ }
79
+ const raw = (result.Results ?? [])[0]?.TotalGross;
80
+ const total = raw === null || raw === undefined ? null : Number(raw);
81
+ if (total === null || !Number.isFinite(total)) {
82
+ /**
83
+ * `OrderHeader.TotalGross` is NULL for an order with no lines — `SUM` over no rows is NULL —
84
+ * so this is the empty-order case, not an error. There is no total to disagree with.
85
+ */
86
+ return FRESH;
87
+ }
88
+ const cachedRaw = input.Amount;
89
+ const cached = cachedRaw === null || cachedRaw === undefined ? null : Number(cachedRaw);
90
+ if (cached === null || !Number.isFinite(cached)) {
91
+ /**
92
+ * A deal claiming a COMPUTED amount that is not a number is the corrupt state `SD23` was written
93
+ * for. Reporting it stale is both true and useful: the notice's own instruction — save the deal
94
+ * — is exactly what repairs it, because the save re-reads the total into the cache.
95
+ */
96
+ return STALE;
97
+ }
98
+ return cached === total ? FRESH : STALE;
99
+ }
100
+ //# sourceMappingURL=amount-freshness.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"amount-freshness.js","sourceRoot":"","sources":["../src/amount-freshness.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,OAAO,EAAE,OAAO,EAAiB,MAAM,sBAAsB,CAAC;AAE9D,MAAM,cAAc,GAAG,kCAAkC,CAAC;AAE1D;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,wBAAwB,GACjC,gGAAgG,CAAC;AAsBrG,MAAM,KAAK,GAAwB,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;AACnF,MAAM,KAAK,GAAwB,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,wBAAwB,EAAE,CAAC,CAAC;AAEtG;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,0BAA0B,CAC5C,KAA+B,EAC/B,WAAsB;IAEtB;;;;;;;OAOG;IACH,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC;QACjB,OAAO,KAAK,CAAC;IACjB,CAAC;IAED,iFAAiF;IACjF,IAAI,CAAC,KAAK,CAAC,gBAAgB,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC;QAC5C,OAAO,KAAK,CAAC;IACjB,CAAC;IAED,MAAM,MAAM,GAAG,MAAM,IAAI,OAAO,EAAE,CAAC,OAAO,CACtC;QACI,UAAU,EAAE,cAAc;QAC1B,WAAW,EAAE,SAAS,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,IAAI,CAAC,GAAG;QAClE,UAAU,EAAE,QAAQ;QACpB,MAAM,EAAE,CAAC,YAAY,CAAC;KACzB,EACD,WAAW,CACd,CAAC;IACF,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC;QACnB,mFAAmF;QACnF,OAAO,KAAK,CAAC;IACjB,CAAC;IAED,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,UAAU,CAAC;IAClD,MAAM,KAAK,GAAG,GAAG,KAAK,IAAI,IAAI,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IACrE,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QAC5C;;;WAGG;QACH,OAAO,KAAK,CAAC;IACjB,CAAC;IAED,MAAM,SAAS,GAAG,KAAK,CAAC,MAAM,CAAC;IAC/B,MAAM,MAAM,GAAG,SAAS,KAAK,IAAI,IAAI,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;IACxF,IAAI,MAAM,KAAK,IAAI,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;QAC9C;;;;WAIG;QACH,OAAO,KAAK,CAAC;IACjB,CAAC;IAED,OAAO,MAAM,KAAK,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC;AAC5C,CAAC"}
@@ -28,25 +28,81 @@
28
28
  */
29
29
  import { type UserInfo } from '@memberjunction/core';
30
30
  /**
31
- * The deal fields that stay editable while the deal is locked.
31
+ * Every field a locked deal still accepts, given its outcome.
32
32
  *
33
- * Pinned by integration check CD14, which closes a deal and then proves each of these is genuinely
34
- * accepted and that a field outside the set is genuinely refused — so this constant cannot quietly
35
- * stop describing what the server does.
33
+ * Takes the flag rather than exposing a flat set, so a caller cannot read the list and forget the
34
+ * condition. That is not hypothetical: four call sites used to read the flat constant directly.
35
+ *
36
+ * @param isLost - whether the deal's PERSISTED status carries `IsLost`. When it cannot be determined,
37
+ * pass `false`: refusing an edit to a closed deal is the cheaper mistake.
38
+ */
39
+ export declare function DealFieldsEditableWhileLocked(isLost: boolean): ReadonlySet<string>;
40
+ /**
41
+ * Whether `fieldName` may still be edited on a locked deal with this outcome.
42
+ *
43
+ * Callers should prefer this to reaching into the sets, so the membership test stays in one place.
44
+ */
45
+ export declare function IsDealFieldEditableWhileLocked(fieldName: string, isLost: boolean): boolean;
46
+ /** The on-screen label for a deal field: the override if there is one, else the field name split into words. */
47
+ export declare function DealFieldLabel(fieldName: string): string;
48
+ /**
49
+ * What the lock notice lists as still editable.
50
+ *
51
+ * DEAL STATUS IS IN HERE AND NOT IN `DealFieldsEditableWhileLocked`, which looks like a
52
+ * contradiction and is not. That set is what the SERVER accepts in a bare save, and golive#205 asks
53
+ * for a bare status write to be refused on every path — the status moves through `Sales.CloseDeal`
54
+ * and `Sales.ReopenDeal`, which the form's status control routes to. So the field IS editable to a
55
+ * person and IS NOT writable by a raw save, and one set cannot say both.
56
+ *
57
+ * The notice describes what a PERSON can do, so it is the one that carries Deal Status.
58
+ *
59
+ * It takes `isLost` because the set it wraps does: golive#206 keeps Loss Notes editable on a lost
60
+ * deal and frozen on a won one, so the notice must name it on one and not the other.
36
61
  */
37
- export declare const DEAL_FIELDS_EDITABLE_WHILE_LOCKED: ReadonlySet<string>;
62
+ export declare function DealFieldsListedAsEditable(isLost: boolean): readonly string[];
38
63
  /**
39
- * Whether `fieldName` may still be edited on a locked deal.
64
+ * Join labels the way a sentence does: "A, B and C".
40
65
  *
41
- * Callers should prefer this to reaching into the set, so the membership test stays in one place if the
42
- * rule ever grows a condition beyond simple membership.
66
+ * Oxford-comma-free and with "and" before the last, because this lands mid-sentence in prose a tester
67
+ * wrote, not in a bulleted list.
43
68
  */
44
- export declare function IsDealFieldEditableWhileLocked(fieldName: string): boolean;
69
+ export declare function JoinLabels(labels: readonly string[]): string;
45
70
  /** What a surface needs to know to render the lock: whether it is on, and what to say about it. */
46
71
  export interface DealLockState {
47
72
  IsLocked: boolean;
48
73
  /** The status' display name, for the notice. Null when not locked. */
49
74
  StatusName: string | null;
75
+ /**
76
+ * Whether the locking status carries `IsLost`, which decides one field: golive#206 keeps Loss
77
+ * Notes editable on a lost deal and frozen on a won one.
78
+ *
79
+ * Resolved HERE rather than by each surface, for the same reason `IsLocked` is: it is read off the
80
+ * status ROW by flag, from the PERSISTED status, and a second implementation gets one of those
81
+ * quietly wrong. `false` on an open deal and on a status that cannot be read — refusing an edit to
82
+ * a closed deal is the cheaper mistake.
83
+ */
84
+ IsLost: boolean;
85
+ /**
86
+ * Whether the PERSISTED status carries `IsWon` — the outcome, not the lock.
87
+ *
88
+ * UNLIKE EVERY OTHER MEMBER HERE, THIS IS NOT GATED ON `LocksDeal`, and the difference is the
89
+ * whole reason it exists. The rest of this shape answers "what may still be edited", a question
90
+ * only a locked deal has. `IsWon` answers "did we win", which an OPEN deal can also answer, and
91
+ * golive#226 asks the Deal header for chips that appear on a won deal and on no other. Gating it
92
+ * on the lock would tie a header decision to a field-editing one, so a deployment whose winning
93
+ * status does not freeze the deal would lose its Order and Contract chips with nothing on screen
94
+ * to explain it.
95
+ *
96
+ * Read off the status ROW by FLAG, like its siblings. Nothing anywhere compares a status name —
97
+ * a deployment may call its winning status "Signed" (§3), and `test:vocabulary-gate` enforces it.
98
+ *
99
+ * `false` when the status cannot be read: a chip that is missing costs a click, and one that
100
+ * should not be there says a deal was won when it was not.
101
+ *
102
+ * golive#231's outcome tiles read it too, and are unaffected by the ungating: every one of those
103
+ * getters tests the CLOSE STAMPS first, so an open won-status deal still reads "Closes".
104
+ */
105
+ IsWon: boolean;
50
106
  /** A ready-to-render explanation, or null when the deal is open. */
51
107
  Notice: string | null;
52
108
  }
@@ -69,41 +125,31 @@ export interface DealLockState {
69
125
  * @param contextUser - Server callers must pass one; the browser omits it.
70
126
  */
71
127
  export declare function ResolveDealLockState(persistedStatusID: string | null | undefined, contextUser?: UserInfo): Promise<DealLockState>;
72
- /**
73
- * What a save knows about a status change, before deciding whether it is a bare close.
74
- *
75
- * Resolved by the caller because two of these need a database read; the decision itself does not, and
76
- * that is the point of separating them.
77
- */
78
- export interface StatusTransitionFacts {
79
- /** False on creation — a deal born closed has no transition to have run. */
80
- IsSaved: boolean;
81
- /** True when `Sales.CloseDeal` (or a reopen) announced itself via `DeclareTransition`. */
82
- HasDeclaredTransition: boolean;
83
- /** True when the caller set `DealStatusTypeID` on this save. */
84
- StatusIsDirty: boolean;
85
- /** Whether the status being moved INTO carries `LocksDeal`. */
86
- TargetLocks: boolean;
87
- /** Whether the status being moved OUT OF carries it. Null when there was none. */
88
- PriorLocks: boolean | null;
128
+ /** One row of the deal-status list, with the flag that decides whether it may be picked. */
129
+ export interface DealStatusOption {
130
+ ID: string;
131
+ Name: string;
132
+ /** Entering this status closes and freezes the deal. Enforced server-side. */
133
+ LocksDeal: boolean;
134
+ /**
135
+ * The OUTCOME flags, carried so a close action can find the status to close INTO by flag rather
136
+ * than by name. A deployment may call its winning status "Signed"; nothing may match on the word.
137
+ */
138
+ IsWon: boolean;
139
+ IsLost: boolean;
89
140
  }
90
141
  /**
91
- * Is this save about to lock a deal without the close having run? (bc-aidp-next-golive#205)
92
- *
93
- * THE DEFECT IT NAMES. The close lock reads the PERSISTED status, so Open -> Won is a save on an
94
- * unlocked deal and passes straight through it. The deal ends up locked with none of the close having
95
- * happened — no stage event, no contract, no finance tasks, no loss reason, an order still live — and
96
- * the lock then refuses `DealStatusTypeID` on every later save, so it cannot be undone either.
97
- *
98
- * WHAT MAKES A CLOSE LEGITIMATE is the declared transition. `Sales.CloseDeal` calls `stampClose`
99
- * immediately before saving, and that declares one; a form writing a field has no way to. So this
100
- * needs no new flag on the entity, and it cannot be spoofed by a caller that does not know about it.
142
+ * Every active deal status, with its lock flag, for a surface that has to offer a choice.
101
143
  *
102
- * LEAVING a locking status is deliberately NOT this function's business. That is a reopen, and the
103
- * close lock already refuses a bare one with a message that names `Sales.ReopenDeal` — two refusals
104
- * for the same edit would be one too many, and the other one is better worded for it.
144
+ * RETURNS ALL OF THEM AND LETS THE CALLER FILTER, deliberately. A closed deal still has to SHOW the
145
+ * status it is in — a control that simply dropped the locking ones would render a won deal as blank
146
+ * or "— choose —", which reads as data loss. The workspace already solves it this way: it offers the
147
+ * non-locking ones and adds the deal's own status back as a display-only option.
105
148
  *
106
- * Pure, so the decision can be pinned without a deal, a status table or a save.
149
+ * `LocksDeal` rather than `IsWon || IsLost`, because that is the flag the server's refusal reads.
150
+ * They coincide on today's data — Won, Lost and Abandoned carry both — but a surface filtering on a
151
+ * different flag would eventually offer a status the server then refuses, which is the drift this
152
+ * module exists to prevent.
107
153
  */
108
- export declare function IsBareCloseWrite(facts: StatusTransitionFacts): boolean;
154
+ export declare function LoadDealStatusOptions(contextUser?: UserInfo): Promise<DealStatusOption[]>;
109
155
  //# sourceMappingURL=close-lock.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"close-lock.d.ts","sourceRoot":"","sources":["../src/close-lock.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,EAAW,KAAK,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAE9D;;;;;;GAMG;AACH,eAAO,MAAM,iCAAiC,EAAE,WAAW,CAAC,MAAM,CAGhE,CAAC;AAEH;;;;;GAKG;AACH,wBAAgB,8BAA8B,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAEzE;AAKD,mGAAmG;AACnG,MAAM,WAAW,aAAa;IAC1B,QAAQ,EAAE,OAAO,CAAC;IAClB,sEAAsE;IACtE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,oEAAoE;IACpE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,oBAAoB,CACtC,iBAAiB,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAC5C,WAAW,CAAC,EAAE,QAAQ,GACvB,OAAO,CAAC,aAAa,CAAC,CA6BxB;AAED;;;;;GAKG;AACH,MAAM,WAAW,qBAAqB;IAClC,4EAA4E;IAC5E,OAAO,EAAE,OAAO,CAAC;IACjB,0FAA0F;IAC1F,qBAAqB,EAAE,OAAO,CAAC;IAC/B,gEAAgE;IAChE,aAAa,EAAE,OAAO,CAAC;IACvB,+DAA+D;IAC/D,WAAW,EAAE,OAAO,CAAC;IACrB,kFAAkF;IAClF,UAAU,EAAE,OAAO,GAAG,IAAI,CAAC;CAC9B;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,qBAAqB,GAAG,OAAO,CAQtE"}
1
+ {"version":3,"file":"close-lock.d.ts","sourceRoot":"","sources":["../src/close-lock.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,EAAW,KAAK,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AA0C9D;;;;;;;;GAQG;AACH,wBAAgB,6BAA6B,CAAC,MAAM,EAAE,OAAO,GAAG,WAAW,CAAC,MAAM,CAAC,CAKlF;AAED;;;;GAIG;AACH,wBAAgB,8BAA8B,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CAE1F;AAkDD,gHAAgH;AAChH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAExD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,OAAO,GAAG,SAAS,MAAM,EAAE,CAE7E;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAI5D;AAKD,mGAAmG;AACnG,MAAM,WAAW,aAAa;IAC1B,QAAQ,EAAE,OAAO,CAAC;IAClB,sEAAsE;IACtE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B;;;;;;;;OAQG;IACH,MAAM,EAAE,OAAO,CAAC;IAChB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,KAAK,EAAE,OAAO,CAAC;IACf,oEAAoE;IACpE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,oBAAoB,CACtC,iBAAiB,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAC5C,WAAW,CAAC,EAAE,QAAQ,GACvB,OAAO,CAAC,aAAa,CAAC,CAqDxB;AAED,4FAA4F;AAC5F,MAAM,WAAW,gBAAgB;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,8EAA8E;IAC9E,SAAS,EAAE,OAAO,CAAC;IACnB;;;OAGG;IACH,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,qBAAqB,CAAC,WAAW,CAAC,EAAE,QAAQ,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAiC/F"}
@@ -28,24 +28,145 @@
28
28
  */
29
29
  import { RunView } from '@memberjunction/core';
30
30
  /**
31
- * The deal fields that stay editable while the deal is locked.
31
+ * The deal fields that stay editable while the deal is locked, on ANY locked deal.
32
32
  *
33
33
  * Pinned by integration check CD14, which closes a deal and then proves each of these is genuinely
34
- * accepted and that a field outside the set is genuinely refused — so this constant cannot quietly
35
- * stop describing what the server does.
34
+ * accepted and that a field outside the set is genuinely refused — so this cannot quietly stop
35
+ * describing what the server does.
36
36
  */
37
- export const DEAL_FIELDS_EDITABLE_WHILE_LOCKED = new Set([
37
+ const EDITABLE_ON_ANY_LOCKED_DEAL = new Set([
38
+ // Commentary. A closed deal still gets notes, and forcing a reopen to add one would corrupt the
39
+ // reopen record with administrative noise.
38
40
  'Description',
41
+ // Follow-up. NextStepDate is here because leaving one of the pair open and the other frozen makes
42
+ // no sense -- a next step nobody may date is half a field.
39
43
  'NextStep',
44
+ 'NextStepDate',
45
+ // Attribution and bookkeeping. Nothing downstream reads these, and they are routinely corrected
46
+ // after the fact: the contract takes the PRIMARY contact, not the billing one, and Lead Source and
47
+ // Campaign exist for reporting.
48
+ 'BillingContactID',
49
+ 'LeadSourceTypeID',
50
+ 'CampaignID',
40
51
  ]);
41
52
  /**
42
- * Whether `fieldName` may still be edited on a locked deal.
53
+ * The fields that stay editable only on a LOST deal.
43
54
  *
44
- * Callers should prefer this to reaching into the set, so the membership test stays in one place if the
45
- * rule ever grows a condition beyond simple membership.
55
+ * golive#206's field table says "Loss Notes (Lost deals only)", and item 3 asks that the server's
56
+ * list "match this list exactly". An earlier version of this module put `LossNotes` in the flat set
57
+ * above and said so out loud: a conditional member would need the form and the server to evaluate the
58
+ * same condition, which is the drift this module exists to prevent.
59
+ *
60
+ * That reasoning was weaker than it read. THIS module is exactly where such a condition belongs --
61
+ * the same place that already owns "how you decide a deal is locked at all". Both sides now pass a
62
+ * flag they already hold, from the same resolver, and the condition itself lives here once.
63
+ *
64
+ * `LossReasonID` stays frozen on every deal, lost included: the close event records which reason was
65
+ * chosen, and rewriting it would make that event dishonest. Notes are the channel for corrections.
66
+ */
67
+ const EDITABLE_ON_A_LOST_DEAL = new Set(['LossNotes']);
68
+ /**
69
+ * Every field a locked deal still accepts, given its outcome.
70
+ *
71
+ * Takes the flag rather than exposing a flat set, so a caller cannot read the list and forget the
72
+ * condition. That is not hypothetical: four call sites used to read the flat constant directly.
73
+ *
74
+ * @param isLost - whether the deal's PERSISTED status carries `IsLost`. When it cannot be determined,
75
+ * pass `false`: refusing an edit to a closed deal is the cheaper mistake.
76
+ */
77
+ export function DealFieldsEditableWhileLocked(isLost) {
78
+ if (!isLost) {
79
+ return EDITABLE_ON_ANY_LOCKED_DEAL;
80
+ }
81
+ return new Set([...EDITABLE_ON_ANY_LOCKED_DEAL, ...EDITABLE_ON_A_LOST_DEAL]);
82
+ }
83
+ /**
84
+ * Whether `fieldName` may still be edited on a locked deal with this outcome.
85
+ *
86
+ * Callers should prefer this to reaching into the sets, so the membership test stays in one place.
87
+ */
88
+ export function IsDealFieldEditableWhileLocked(fieldName, isLost) {
89
+ return EDITABLE_ON_ANY_LOCKED_DEAL.has(fieldName) || (isLost && EDITABLE_ON_A_LOST_DEAL.has(fieldName));
90
+ }
91
+ /**
92
+ * What each editable-while-locked field is CALLED on screen.
93
+ *
94
+ * The lock notice used to interpolate the raw field names, so a user read "only Description and
95
+ * NextStep can still be changed" — `NextStep` being a column name that appears nowhere on the form.
96
+ * golive#207 asks for the labels a salesperson sees.
97
+ *
98
+ * Falling back to the field name is deliberate rather than throwing: a field added to the set without
99
+ * a label here should read slightly wrong, not take the whole notice down. `DealFieldLabel` is
100
+ * exported so a test can prove every member of the set has one.
101
+ */
102
+ const DEAL_FIELD_LABELS = {
103
+ Description: 'Description',
104
+ NextStep: 'Next Step',
105
+ NextStepDate: 'Next Step Date',
106
+ BillingContactID: 'Billing Contact',
107
+ LeadSourceTypeID: 'Lead Source',
108
+ CampaignID: 'Campaign',
109
+ LossNotes: 'Loss Notes',
110
+ DealStatusTypeID: 'Deal Status',
111
+ };
112
+ /**
113
+ * Splits a column name into words and drops a trailing `ID`: `ExpectedCloseDate` -> `Expected Close
114
+ * Date`, `BillingContactID` -> `Billing Contact`.
115
+ *
116
+ * THIS EXISTS BECAUSE THE MAP ABOVE COVERS THE WRONG HALF OF THE RULE. It holds the fields the lock
117
+ * leaves EDITABLE -- what row 16 lists. Row 18 names the FROZEN fields, and there are far more of
118
+ * those, none of them in the map: `Amount`, `ExpectedCloseDate`, `AnnualIncreasePctOverride` and every
119
+ * other column a form or an importer can set. Falling back to the raw field name meant row 18 read
120
+ * "ExpectedCloseDate, AnnualIncreasePctOverride cannot be changed" -- the exact "NextStep being a
121
+ * column name that appears nowhere on the form" complaint the map was added to answer, on the message
122
+ * a user is far more likely to see.
123
+ *
124
+ * Deriving rather than hand-listing the frozen set, because a hand list would have to be extended
125
+ * every time a column is added and would read correctly right up until somebody forgot -- the same
126
+ * shape as the gap it is replacing. The map stays for the two labels a split cannot produce
127
+ * (`LeadSourceTypeID` -> "Lead Source", not "Lead Source Type") and is now an override rather than
128
+ * the only source of a label.
46
129
  */
47
- export function IsDealFieldEditableWhileLocked(fieldName) {
48
- return DEAL_FIELDS_EDITABLE_WHILE_LOCKED.has(fieldName);
130
+ function SplitFieldName(fieldName) {
131
+ return fieldName
132
+ .replace(/ID$/, '')
133
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
134
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
135
+ .trim();
136
+ }
137
+ /** The on-screen label for a deal field: the override if there is one, else the field name split into words. */
138
+ export function DealFieldLabel(fieldName) {
139
+ return DEAL_FIELD_LABELS[fieldName] ?? SplitFieldName(fieldName);
140
+ }
141
+ /**
142
+ * What the lock notice lists as still editable.
143
+ *
144
+ * DEAL STATUS IS IN HERE AND NOT IN `DealFieldsEditableWhileLocked`, which looks like a
145
+ * contradiction and is not. That set is what the SERVER accepts in a bare save, and golive#205 asks
146
+ * for a bare status write to be refused on every path — the status moves through `Sales.CloseDeal`
147
+ * and `Sales.ReopenDeal`, which the form's status control routes to. So the field IS editable to a
148
+ * person and IS NOT writable by a raw save, and one set cannot say both.
149
+ *
150
+ * The notice describes what a PERSON can do, so it is the one that carries Deal Status.
151
+ *
152
+ * It takes `isLost` because the set it wraps does: golive#206 keeps Loss Notes editable on a lost
153
+ * deal and frozen on a won one, so the notice must name it on one and not the other.
154
+ */
155
+ export function DealFieldsListedAsEditable(isLost) {
156
+ return ['DealStatusTypeID', ...DealFieldsEditableWhileLocked(isLost)];
157
+ }
158
+ /**
159
+ * Join labels the way a sentence does: "A, B and C".
160
+ *
161
+ * Oxford-comma-free and with "and" before the last, because this lands mid-sentence in prose a tester
162
+ * wrote, not in a bulleted list.
163
+ */
164
+ export function JoinLabels(labels) {
165
+ if (labels.length === 0)
166
+ return '';
167
+ if (labels.length === 1)
168
+ return labels[0];
169
+ return `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}`;
49
170
  }
50
171
  /** Sales' deal-status type table. Named here so the lock lookup below has one spelling of it. */
51
172
  const E_DEAL_STATUS_TYPE = 'MJ_BizApps_Sales: Deal Status Types';
@@ -68,7 +189,7 @@ const E_DEAL_STATUS_TYPE = 'MJ_BizApps_Sales: Deal Status Types';
68
189
  * @param contextUser - Server callers must pass one; the browser omits it.
69
190
  */
70
191
  export async function ResolveDealLockState(persistedStatusID, contextUser) {
71
- const open = { IsLocked: false, StatusName: null, Notice: null };
192
+ const open = { IsLocked: false, StatusName: null, IsLost: false, IsWon: false, Notice: null };
72
193
  if (!persistedStatusID) {
73
194
  return open;
74
195
  }
@@ -76,46 +197,80 @@ export async function ResolveDealLockState(persistedStatusID, contextUser) {
76
197
  EntityName: E_DEAL_STATUS_TYPE,
77
198
  ExtraFilter: `ID = '${String(persistedStatusID).replace(/'/g, "''")}'`,
78
199
  ResultType: 'simple',
79
- Fields: ['LocksDeal', 'Name'],
200
+ Fields: ['LocksDeal', 'Name', 'IsLost', 'IsWon'],
80
201
  }, contextUser);
81
202
  const row = result?.Success ? (result.Results ?? [])[0] : undefined;
203
+ /**
204
+ * THE OUTCOME SURVIVES THE EARLY RETURN, THE LOCK DOES NOT.
205
+ *
206
+ * `IsWon` is a fact about the status itself, so it is carried out of every exit below rather than
207
+ * being reset to `false` by the unlocked one. The cost of getting this backwards is silent: the
208
+ * header would simply draw no chips on a won-but-unlocked deal, which looks exactly like a deal
209
+ * with no order and no contract.
210
+ */
211
+ const isWon = row?.IsWon === true;
82
212
  if (!row?.LocksDeal) {
83
- return open;
213
+ return { ...open, IsWon: isWon };
84
214
  }
85
- const editable = [...DEAL_FIELDS_EDITABLE_WHILE_LOCKED].join(' and ');
215
+ /**
216
+ * golive#207 row 16, in the tester's words: "This deal is closed (Won). Only Deal Status,
217
+ * Description and Next Step can be edited. To change anything else, set the status back to Open."
218
+ *
219
+ * DERIVED rather than hardcoded to those three. The tester wrote that list when the editable set
220
+ * held two fields; golive#206 item 3 expands it. A hardcoded sentence would have started lying the
221
+ * moment that landed, and the lie would be invisible — it reads perfectly either way.
222
+ *
223
+ * What went, and why it is no loss: the old notice explained WHY the deal is frozen ("a contract or
224
+ * an order was derived from it"). A person who has just been stopped wants to know what they can do,
225
+ * not the provenance argument, and the reason is one click away in the close history.
226
+ */
227
+ const isLost = row.IsLost === true;
228
+ const editable = JoinLabels(DealFieldsListedAsEditable(isLost).map(DealFieldLabel));
86
229
  return {
87
230
  IsLocked: true,
88
231
  StatusName: row.Name,
89
- Notice: `This deal is closed (${row.Name}) and locked. A contract or an order was derived from it, so ` +
90
- `its terms are frozen — only ${editable} can still be changed. To change anything else, reopen ` +
91
- 'the deal, which records a reason.',
232
+ IsLost: isLost,
233
+ IsWon: isWon,
234
+ Notice: `This deal is closed (${row.Name}). Only ${editable} can be edited. ` +
235
+ 'To change anything else, set the status back to Open.',
92
236
  };
93
237
  }
94
238
  /**
95
- * Is this save about to lock a deal without the close having run? (bc-aidp-next-golive#205)
96
- *
97
- * THE DEFECT IT NAMES. The close lock reads the PERSISTED status, so Open -> Won is a save on an
98
- * unlocked deal and passes straight through it. The deal ends up locked with none of the close having
99
- * happened — no stage event, no contract, no finance tasks, no loss reason, an order still live — and
100
- * the lock then refuses `DealStatusTypeID` on every later save, so it cannot be undone either.
239
+ * Every active deal status, with its lock flag, for a surface that has to offer a choice.
101
240
  *
102
- * WHAT MAKES A CLOSE LEGITIMATE is the declared transition. `Sales.CloseDeal` calls `stampClose`
103
- * immediately before saving, and that declares one; a form writing a field has no way to. So this
104
- * needs no new flag on the entity, and it cannot be spoofed by a caller that does not know about it.
241
+ * RETURNS ALL OF THEM AND LETS THE CALLER FILTER, deliberately. A closed deal still has to SHOW the
242
+ * status it is in — a control that simply dropped the locking ones would render a won deal as blank
243
+ * or "— choose —", which reads as data loss. The workspace already solves it this way: it offers the
244
+ * non-locking ones and adds the deal's own status back as a display-only option.
105
245
  *
106
- * LEAVING a locking status is deliberately NOT this function's business. That is a reopen, and the
107
- * close lock already refuses a bare one with a message that names `Sales.ReopenDeal` — two refusals
108
- * for the same edit would be one too many, and the other one is better worded for it.
109
- *
110
- * Pure, so the decision can be pinned without a deal, a status table or a save.
246
+ * `LocksDeal` rather than `IsWon || IsLost`, because that is the flag the server's refusal reads.
247
+ * They coincide on today's data — Won, Lost and Abandoned carry both — but a surface filtering on a
248
+ * different flag would eventually offer a status the server then refuses, which is the drift this
249
+ * module exists to prevent.
111
250
  */
112
- export function IsBareCloseWrite(facts) {
113
- if (!facts.IsSaved || facts.HasDeclaredTransition || !facts.StatusIsDirty) {
114
- return false;
115
- }
116
- if (facts.PriorLocks === true) {
117
- return false; // a reopen attempt; the close lock owns that refusal
251
+ export async function LoadDealStatusOptions(contextUser) {
252
+ const result = await new RunView().RunView({
253
+ EntityName: E_DEAL_STATUS_TYPE,
254
+ ExtraFilter: 'IsActive = 1',
255
+ OrderBy: 'DisplayRank',
256
+ ResultType: 'simple',
257
+ // Every field read below must be listed here. A field declared on the row type and left
258
+ // out of this list arrives `undefined`, and a flag check against it quietly never fires --
259
+ // which is how ActivitySyncProviderType.IsActive did nothing for a release.
260
+ Fields: ['ID', 'Name', 'LocksDeal', 'IsWon', 'IsLost'],
261
+ }, contextUser);
262
+ if (!result?.Success) {
263
+ // An empty list leaves the control with nothing to offer, which is visibly wrong and therefore
264
+ // reportable. Inventing a list from somewhere else would hide a failed lookup behind a control
265
+ // that looks like it is working.
266
+ return [];
118
267
  }
119
- return facts.TargetLocks;
268
+ return (result.Results ?? []).map((r) => ({
269
+ ID: String(r.ID),
270
+ Name: String(r.Name),
271
+ LocksDeal: r.LocksDeal === true,
272
+ IsWon: r.IsWon === true,
273
+ IsLost: r.IsLost === true,
274
+ }));
120
275
  }
121
276
  //# sourceMappingURL=close-lock.js.map