@mj-biz-apps/sales-entities 6.2.0 → 6.3.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.
package/dist/index.d.ts CHANGED
@@ -28,6 +28,7 @@ export * from './downstream-seams.js';
28
28
  * all apply the same one.
29
29
  */
30
30
  export * from './product-filter.js';
31
+ export * from './term-start.js';
31
32
  /**
32
33
  * The close lock's field rule, shared by `DealEntityServer.Save()` and the Explorer Deal form so the
33
34
  * form cannot offer a field the server will refuse. Pinned by integration check CD14.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,+BAA+B,CAAC;AAE9C;;;;GAIG;AACH,cAAc,+BAA+B,CAAC;AAE9C;;;;;;;;;GASG;AACH,cAAc,eAAe,CAAC;AAE9B;;;;GAIG;AACH,cAAc,oBAAoB,CAAC;AAEnC;;;;GAIG;AACH,cAAc,kBAAkB,CAAC;AAEjC;;;GAGG;AACH,cAAc,cAAc,CAAC;AAE7B;;;;;GAKG;AACH,wBAAgB,qBAAqB,IAAI,IAAI,CAC5C;AACD,cAAc,0BAA0B,CAAC;AAEzC,wFAAwF;AACxF,cAAc,qCAAqC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,+BAA+B,CAAC;AAE9C;;;;GAIG;AACH,cAAc,+BAA+B,CAAC;AAE9C;;;;;;;;;GASG;AACH,cAAc,eAAe,CAAC;AAE9B;;;;GAIG;AACH,cAAc,oBAAoB,CAAC;AAEnC;;;;GAIG;AACH,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAE7B;;;GAGG;AACH,cAAc,cAAc,CAAC;AAE7B;;;;;GAKG;AACH,wBAAgB,qBAAqB,IAAI,IAAI,CAC5C;AACD,cAAc,0BAA0B,CAAC;AAEzC,wFAAwF;AACxF,cAAc,qCAAqC,CAAC"}
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ export * from './downstream-seams.js';
28
28
  * all apply the same one.
29
29
  */
30
30
  export * from './product-filter.js';
31
+ export * from './term-start.js';
31
32
  /**
32
33
  * The close lock's field rule, shared by `DealEntityServer.Save()` and the Explorer Deal form so the
33
34
  * form cannot offer a field the server will refuse. Pinned by integration check CD14.
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,+BAA+B,CAAC;AAE9C;;;;GAIG;AACH,cAAc,+BAA+B,CAAC;AAE9C;;;;;;;;;GASG;AACH,cAAc,eAAe,CAAC;AAE9B;;;;GAIG;AACH,cAAc,oBAAoB,CAAC;AAEnC;;;;GAIG;AACH,cAAc,kBAAkB,CAAC;AAEjC;;;GAGG;AACH,cAAc,cAAc,CAAC;AAE7B;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB;AACrC,CAAC;AACD,cAAc,0BAA0B,CAAC;AAEzC,wFAAwF;AACxF,cAAc,qCAAqC,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,+BAA+B,CAAC;AAE9C;;;;GAIG;AACH,cAAc,+BAA+B,CAAC;AAE9C;;;;;;;;;GASG;AACH,cAAc,eAAe,CAAC;AAE9B;;;;GAIG;AACH,cAAc,oBAAoB,CAAC;AAEnC;;;;GAIG;AACH,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAE7B;;;GAGG;AACH,cAAc,cAAc,CAAC;AAE7B;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB;AACrC,CAAC;AACD,cAAc,0BAA0B,CAAC;AAEzC,wFAAwF;AACxF,cAAc,qCAAqC,CAAC"}
@@ -82,7 +82,23 @@ export interface ProductLookup {
82
82
  * It is a virtual field on orders' Products view, so it costs nothing but a column.
83
83
  */
84
84
  Company: string | null;
85
+ /**
86
+ * Non-null when this product is sold as a subscription — read only for WHETHER it is set, never for
87
+ * its value. The rule that consumes it lives in `term-start.ts`, with the reasoning.
88
+ */
89
+ SubscriptionTypeID: string | null;
85
90
  }
91
+ /**
92
+ * The columns the picker reads, as ONE exported list rather than a literal at the call site.
93
+ *
94
+ * WHY IT IS SHARED. `SubscriptionTypeID` is not displayed anywhere — it only decides whether a line
95
+ * offers a term start (#32). Drop it from the query and every product arrives without it, so no line is
96
+ * a subscription, no term start appears, and NOTHING reports an error: the screen quietly loses a
97
+ * feature. The integration check that guards against exactly that (`term-start.TS5`) has to read the
98
+ * same list the picker reads, or it proves only that its own copy still works — which is the drift this
99
+ * suite already warns about for hardcoded SKUs and re-typed filters.
100
+ */
101
+ export declare const PRODUCT_LOOKUP_FIELDS: readonly ["ID", "Name", "SKU", "CompanyID", "Company", "SubscriptionTypeID"];
86
102
  /**
87
103
  * The filter that decides what a rep may select, as of `asOf`.
88
104
  *
@@ -1 +1 @@
1
- {"version":3,"file":"product-filter.d.ts","sourceRoot":"","sources":["../src/product-filter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,6FAA6F;AAC7F,eAAO,MAAM,gBAAgB,gCAAgC,CAAC;AAE9D;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB;;;;;;;;OAQG;IACH,SAAS,EAAE,MAAM,CAAC;IAElB;;;;;;;;;;OAUG;IACH,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAOnD"}
1
+ {"version":3,"file":"product-filter.d.ts","sourceRoot":"","sources":["../src/product-filter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,6FAA6F;AAC7F,eAAO,MAAM,gBAAgB,gCAAgC,CAAC;AAE9D;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB;;;;;;;;OAQG;IACH,SAAS,EAAE,MAAM,CAAC;IAElB;;;;;;;;;;OAUG;IACH,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAEvB;;;OAGG;IACH,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;CACrC;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,qBAAqB,8EAmBmB,CAAC;AAiBtD;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAOnD"}
@@ -50,6 +50,38 @@
50
50
  */
51
51
  /** Orders' Products entity. A SOFT reference — no FK crosses the schema boundary (D-SW3). */
52
52
  export const E_ORDERS_PRODUCT = 'MJ_BizApps_Orders: Products';
53
+ /**
54
+ * The columns the picker reads, as ONE exported list rather than a literal at the call site.
55
+ *
56
+ * WHY IT IS SHARED. `SubscriptionTypeID` is not displayed anywhere — it only decides whether a line
57
+ * offers a term start (#32). Drop it from the query and every product arrives without it, so no line is
58
+ * a subscription, no term start appears, and NOTHING reports an error: the screen quietly loses a
59
+ * feature. The integration check that guards against exactly that (`term-start.TS5`) has to read the
60
+ * same list the picker reads, or it proves only that its own copy still works — which is the drift this
61
+ * suite already warns about for hardcoded SKUs and re-typed filters.
62
+ */
63
+ export const PRODUCT_LOOKUP_FIELDS = [
64
+ 'ID',
65
+ 'Name',
66
+ 'SKU',
67
+ // #29: the line's company comes from the product, and the label names the owner so a rep can tell
68
+ // two same-named products apart. Drop either and the picker stamps undefined or loses the company.
69
+ 'CompanyID',
70
+ 'Company',
71
+ // #32: decides whether a line offers a term start at all. Drop it and the control silently vanishes
72
+ // from every line — which is what `term-start.TS5` exists to catch.
73
+ 'SubscriptionTypeID',
74
+ /**
75
+ * `as const satisfies` rather than a type ANNOTATION, and the difference is the whole point.
76
+ *
77
+ * Annotating this `readonly (keyof ProductLookup)[]` widens `[number]` to `keyof ProductLookup`,
78
+ * which makes the completeness check below `Exclude<K, K>` — always `never`, always true. Measured:
79
+ * with the annotation, dropping 'CompanyID' still compiled. `satisfies` checks each member against
80
+ * the interface without discarding the literal types.
81
+ */
82
+ ];
83
+ const _everyLookupFieldIsRequested = true;
84
+ void _everyLookupFieldIsRequested;
53
85
  /**
54
86
  * The filter that decides what a rep may select, as of `asOf`.
55
87
  *
@@ -1 +1 @@
1
- {"version":3,"file":"product-filter.js","sourceRoot":"","sources":["../src/product-filter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,6FAA6F;AAC7F,MAAM,CAAC,MAAM,gBAAgB,GAAG,6BAA6B,CAAC;AAqC9D;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAU;IACvC,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,cAAc,EAAE,IAAI,MAAM,CAAC,IAAI,CAAC,WAAW,EAAE,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC;IACxI,OAAO,CACH,oBAAoB;QACpB,mDAAmD,GAAG,KAAK;QAC3D,+CAA+C,GAAG,IAAI,CACzD,CAAC;AACN,CAAC"}
1
+ {"version":3,"file":"product-filter.js","sourceRoot":"","sources":["../src/product-filter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,6FAA6F;AAC7F,MAAM,CAAC,MAAM,gBAAgB,GAAG,6BAA6B,CAAC;AA2C9D;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG;IACjC,IAAI;IACJ,MAAM;IACN,KAAK;IACL,kGAAkG;IAClG,mGAAmG;IACnG,WAAW;IACX,SAAS;IACT,oGAAoG;IACpG,oEAAoE;IACpE,oBAAoB;IACpB;;;;;;;OAOG;CAC8C,CAAC;AActD,MAAM,4BAA4B,GAAyD,IAAI,CAAC;AAChG,KAAK,4BAA4B,CAAC;AAElC;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAU;IACvC,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,cAAc,EAAE,IAAI,MAAM,CAAC,IAAI,CAAC,WAAW,EAAE,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC;IACxI,OAAO,CACH,oBAAoB;QACpB,mDAAmD,GAAG,KAAK;QAC3D,+CAA+C,GAAG,IAAI,CACzD,CAAC;AACN,CAAC"}
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @fileoverview The term-start rule for a deal's subscription lines (sales#32).
3
+ *
4
+ * WHY THIS IS A MODULE AND NOT THREE METHODS ON THE WORKSPACE COMPONENT. Two of the three decisions
5
+ * here are the kind that look obviously right and are quietly wrong — which of them applies is decided
6
+ * by whether a value is *stored* versus *shown*, and those are indistinguishable in a date control on
7
+ * screen. Kept as pure functions they can be asserted directly by the integration checks, which is the
8
+ * same shape the discount rules (`DiscountFractionToPercent`) and the picker filter (`ProductFilterFor`)
9
+ * already have in this package: the rule lives here, the component delegates, the checks prove it.
10
+ *
11
+ * ── WHAT SALES IS ACTUALLY DOING HERE ───────────────────────────────────────────────────────────────
12
+ *
13
+ * Writing ONE column, `OrderLine.ServicePeriodStart`, and nothing else. The term END is orders' to
14
+ * compute at confirm from the subscription's own cadence and proration, and sales does not know either.
15
+ * This app states intent; orders states the term. Guessing an end date here would be sales computing a
16
+ * commercial term, which is the boundary rule this repo does not cross.
17
+ *
18
+ * ── AND WHAT IT DEPENDS ON ──────────────────────────────────────────────────────────────────────────
19
+ *
20
+ * As of this writing orders OVERWRITES the column at confirm:
21
+ * `OrderEntityServer.materializeSubscriptions` ends with "the line's stored service period reflects the
22
+ * TERM, not what a user typed" and assigns `line.ServicePeriodStart = term.StartDate`, where the term
23
+ * start comes from `SubscriptionBehavior.ComputeStartDate`. The companion orders issue changes that hook
24
+ * to honour an explicitly-set value. Until it lands, everything in this module works and survives save
25
+ * and reload, and the confirmed term still starts on the order date.
26
+ *
27
+ * @module @mj-biz-apps/sales-entities
28
+ */
29
+ import type { ProductLookup } from './product-filter.js';
30
+ /** Either shape a date can arrive in — an entity field holds a `Date`, a `RunView` row can hold a string. */
31
+ export type DateLike = Date | string | null | undefined;
32
+ /**
33
+ * Whether a product is sold as a subscription.
34
+ *
35
+ * ── THE PREDICATE IS ORDERS', NOT OURS ──
36
+ *
37
+ * `OrderEntityServer.subscriptionLines` selects the lines that will receive subscriptions with
38
+ * `ID IN (...) AND SubscriptionTypeID IS NOT NULL` against this same column. Matching it exactly means
39
+ * a line offering a term start is exactly a line that will receive a term at confirm. Any other test —
40
+ * a product-type string, a name convention — could drift from that and offer a term start on a line
41
+ * which never gets one, which reads as a broken promise rather than a field that does nothing.
42
+ *
43
+ * A function rather than a bare `!= null` at each call site because the emptiness test is the subtle
44
+ * part: `RunView`'s `simple` result type returns an absent uniqueidentifier as `null`, but a row read
45
+ * another way can carry `''`, and `'' != null` is true — which would mark every non-subscription
46
+ * product as a subscription. Both shapes are rejected here, once.
47
+ */
48
+ export declare function IsSubscriptionProduct(product: Pick<ProductLookup, 'SubscriptionTypeID'> | null | undefined): boolean;
49
+ /**
50
+ * Whether a line shows a term start at all.
51
+ *
52
+ * Three cases, and the third is the one worth reading:
53
+ *
54
+ * · **No product chosen** — nothing to say. A line with no `ProductID` cannot be a subscription, and it
55
+ * already blocks the save on its own account (`UnlinkedLineIssues`).
56
+ * · **Product in the catalogue** — orders' predicate decides, and that is #32's requirement 3 exactly,
57
+ * BUT ONLY WHEN NOTHING IS STORED. A stored value short-circuits every case here: a term start that
58
+ * exists is always offered, whatever the product turns out to be, so it can be seen and cleared
59
+ * rather than governing the line invisibly. That ordering is the fix in the body below, and this
60
+ * list described the rule before it — long enough that the next reader implementing requirement 3
61
+ * elsewhere would have copied the stranding bug back in.
62
+ * · **Product NOT in the catalogue** — quoted before it was withdrawn; `ProductLabel` renders it as
63
+ * "(no longer offered)". The lookup cannot say whether it was a subscription, and answering `false`
64
+ * would HIDE a term start the rep had already set: the value stays in the database, still governs the
65
+ * term, and has no control on screen to see or clear it. So the stored value decides — shown if there
66
+ * is one, absent if there is not. Nothing is invented for a line that never had one.
67
+ *
68
+ * @param hasProduct - Whether the line references a product at all.
69
+ * @param product - The catalogue row, or null when the product is no longer offered.
70
+ * @param stored - What the line currently stores in `ServicePeriodStart`.
71
+ */
72
+ export declare function ShouldOfferTermStart(hasProduct: boolean, product: Pick<ProductLookup, 'SubscriptionTypeID'> | null | undefined, stored: DateLike): boolean;
73
+ /**
74
+ * The date the control DISPLAYS: the stored term start, or the order date as a default.
75
+ *
76
+ * ── THE DEFAULT IS DISPLAYED, NEVER WRITTEN ──
77
+ *
78
+ * This is the whole of #32's behaviour, and the two acceptance criteria that look like opposites are
79
+ * one rule seen twice: with nothing stored the field follows the order date, and the moment a value is
80
+ * stored it stops following. Writing the default onto the line at load would collapse both into "always
81
+ * frozen", would make every line dirty just by opening the deal, and would leave "reset to order date"
82
+ * with nothing to distinguish it from setting today's date by hand.
83
+ *
84
+ * ── EMPTINESS, NOT NULLISHNESS ──
85
+ *
86
+ * The fallback triggers on {@link IsEmptyDateLike}, deliberately, and NOT on `??`. This read
87
+ * `stored ?? orderDate` once, which meant it fell back on null and undefined only — and `'' ?? x` is
88
+ * `''`. `DateLike` explicitly admits `string`, and a cleared `<input type="date">` reports exactly
89
+ * `''`, so the code produced the outcome the paragraph above rejects: a blank date box captioned
90
+ * "order date", with no reset button, because `HasExplicitTermStart('')` is false while
91
+ * `EffectiveTermStart('')` returned the empty string.
92
+ *
93
+ * `||` would have fixed that case and taken a legitimate falsy with it. An explicit emptiness test is
94
+ * cheaper to read than re-deriving which falsy values `DateLike` can hold.
95
+ */
96
+ export declare function EffectiveTermStart(stored: DateLike, orderDate: DateLike): DateLike;
97
+ /**
98
+ * Whether the line carries its own term start rather than showing the order date.
99
+ *
100
+ * The one thing a date control cannot show by itself, and the reason the workspace prints "order date"
101
+ * under an inherited value: an inherited default and a deliberately-chosen date look identical on
102
+ * screen and behave differently the moment the order date moves.
103
+ */
104
+ export declare function HasExplicitTermStart(stored: DateLike): boolean;
105
+ //# sourceMappingURL=term-start.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"term-start.d.ts","sourceRoot":"","sources":["../src/term-start.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,6GAA6G;AAC7G,MAAM,MAAM,QAAQ,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;AAExD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,qBAAqB,CACjC,OAAO,EAAE,IAAI,CAAC,aAAa,EAAE,oBAAoB,CAAC,GAAG,IAAI,GAAG,SAAS,GACtE,OAAO,CAET;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,oBAAoB,CAChC,UAAU,EAAE,OAAO,EACnB,OAAO,EAAE,IAAI,CAAC,aAAa,EAAE,oBAAoB,CAAC,GAAG,IAAI,GAAG,SAAS,EACrE,MAAM,EAAE,QAAQ,GACjB,OAAO,CAgCT;AAoBD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,GAAG,QAAQ,CAElF;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,QAAQ,GAAG,OAAO,CAU9D"}
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Whether a product is sold as a subscription.
3
+ *
4
+ * ── THE PREDICATE IS ORDERS', NOT OURS ──
5
+ *
6
+ * `OrderEntityServer.subscriptionLines` selects the lines that will receive subscriptions with
7
+ * `ID IN (...) AND SubscriptionTypeID IS NOT NULL` against this same column. Matching it exactly means
8
+ * a line offering a term start is exactly a line that will receive a term at confirm. Any other test —
9
+ * a product-type string, a name convention — could drift from that and offer a term start on a line
10
+ * which never gets one, which reads as a broken promise rather than a field that does nothing.
11
+ *
12
+ * A function rather than a bare `!= null` at each call site because the emptiness test is the subtle
13
+ * part: `RunView`'s `simple` result type returns an absent uniqueidentifier as `null`, but a row read
14
+ * another way can carry `''`, and `'' != null` is true — which would mark every non-subscription
15
+ * product as a subscription. Both shapes are rejected here, once.
16
+ */
17
+ export function IsSubscriptionProduct(product) {
18
+ return !!product && String(product.SubscriptionTypeID ?? '').trim().length > 0;
19
+ }
20
+ /**
21
+ * Whether a line shows a term start at all.
22
+ *
23
+ * Three cases, and the third is the one worth reading:
24
+ *
25
+ * · **No product chosen** — nothing to say. A line with no `ProductID` cannot be a subscription, and it
26
+ * already blocks the save on its own account (`UnlinkedLineIssues`).
27
+ * · **Product in the catalogue** — orders' predicate decides, and that is #32's requirement 3 exactly,
28
+ * BUT ONLY WHEN NOTHING IS STORED. A stored value short-circuits every case here: a term start that
29
+ * exists is always offered, whatever the product turns out to be, so it can be seen and cleared
30
+ * rather than governing the line invisibly. That ordering is the fix in the body below, and this
31
+ * list described the rule before it — long enough that the next reader implementing requirement 3
32
+ * elsewhere would have copied the stranding bug back in.
33
+ * · **Product NOT in the catalogue** — quoted before it was withdrawn; `ProductLabel` renders it as
34
+ * "(no longer offered)". The lookup cannot say whether it was a subscription, and answering `false`
35
+ * would HIDE a term start the rep had already set: the value stays in the database, still governs the
36
+ * term, and has no control on screen to see or clear it. So the stored value decides — shown if there
37
+ * is one, absent if there is not. Nothing is invented for a line that never had one.
38
+ *
39
+ * @param hasProduct - Whether the line references a product at all.
40
+ * @param product - The catalogue row, or null when the product is no longer offered.
41
+ * @param stored - What the line currently stores in `ServicePeriodStart`.
42
+ */
43
+ export function ShouldOfferTermStart(hasProduct, product, stored) {
44
+ if (!hasProduct) {
45
+ return false;
46
+ }
47
+ /**
48
+ * A STORED VALUE IS OFFERED REGARDLESS OF THE PRODUCT, and that ordering is the fix.
49
+ *
50
+ * This used to read `product ? IsSubscriptionProduct(product) : !!stored`, so the `stored` fallback
51
+ * applied only when the product was UNKNOWN. When the product was known and was not a subscription
52
+ * the answer was `false` no matter what was stored — the exact outcome this function's docstring
53
+ * says it prevents: "the value stays in the database, still governs the term, and has no control on
54
+ * screen to see or clear it".
55
+ *
56
+ * It is reachable in one click. A rep sets a term start on a subscription line, then re-picks that
57
+ * row's product as a one-time item. Nothing on either side reconciles the column —
58
+ * `OrderLineEntity` and `OrderLineEntityServer` never mention `ServicePeriodStart`, and orders'
59
+ * subscription materialisation only visits lines carrying a `SubscriptionTypeID` — so the value is
60
+ * written once and then never read, never cleared, and never seen.
61
+ *
62
+ * Worse than invisible: orders' confirm bails out of stamping an event line's own service period
63
+ * with `if (line.ServicePeriodStart || line.ServicePeriodEnd) return;`, and revenue recognition
64
+ * then throws for want of a service period. A stranded value turns into a failed confirm naming
65
+ * nothing the rep did.
66
+ *
67
+ * `OnProductChange` now clears the column when the newly chosen product is known and is not a
68
+ * subscription, which stops the stranding at its source. This is the second half: anything stranded
69
+ * by another route stays visible and clearable rather than silently governing a term.
70
+ */
71
+ if (HasExplicitTermStart(stored)) {
72
+ return true;
73
+ }
74
+ return product ? IsSubscriptionProduct(product) : false;
75
+ }
76
+ /**
77
+ * Is this `DateLike` absent for our purposes?
78
+ *
79
+ * Absent means null, undefined, a blank or whitespace-only string, or a Date that does not parse. The
80
+ * three predicates in this module disagreed about that before — `??` passed `''` through while `!!`
81
+ * rejected it, so one function said "no term start" while another rendered the empty string — and a
82
+ * shared test is what stops them drifting again.
83
+ */
84
+ function IsEmptyDateLike(value) {
85
+ if (value === null || value === undefined) {
86
+ return true;
87
+ }
88
+ if (value instanceof Date) {
89
+ return Number.isNaN(value.getTime());
90
+ }
91
+ return String(value).trim().length === 0;
92
+ }
93
+ /**
94
+ * The date the control DISPLAYS: the stored term start, or the order date as a default.
95
+ *
96
+ * ── THE DEFAULT IS DISPLAYED, NEVER WRITTEN ──
97
+ *
98
+ * This is the whole of #32's behaviour, and the two acceptance criteria that look like opposites are
99
+ * one rule seen twice: with nothing stored the field follows the order date, and the moment a value is
100
+ * stored it stops following. Writing the default onto the line at load would collapse both into "always
101
+ * frozen", would make every line dirty just by opening the deal, and would leave "reset to order date"
102
+ * with nothing to distinguish it from setting today's date by hand.
103
+ *
104
+ * ── EMPTINESS, NOT NULLISHNESS ──
105
+ *
106
+ * The fallback triggers on {@link IsEmptyDateLike}, deliberately, and NOT on `??`. This read
107
+ * `stored ?? orderDate` once, which meant it fell back on null and undefined only — and `'' ?? x` is
108
+ * `''`. `DateLike` explicitly admits `string`, and a cleared `<input type="date">` reports exactly
109
+ * `''`, so the code produced the outcome the paragraph above rejects: a blank date box captioned
110
+ * "order date", with no reset button, because `HasExplicitTermStart('')` is false while
111
+ * `EffectiveTermStart('')` returned the empty string.
112
+ *
113
+ * `||` would have fixed that case and taken a legitimate falsy with it. An explicit emptiness test is
114
+ * cheaper to read than re-deriving which falsy values `DateLike` can hold.
115
+ */
116
+ export function EffectiveTermStart(stored, orderDate) {
117
+ return IsEmptyDateLike(stored) ? (orderDate ?? null) : stored;
118
+ }
119
+ /**
120
+ * Whether the line carries its own term start rather than showing the order date.
121
+ *
122
+ * The one thing a date control cannot show by itself, and the reason the workspace prints "order date"
123
+ * under an inherited value: an inherited default and a deliberately-chosen date look identical on
124
+ * screen and behave differently the moment the order date moves.
125
+ */
126
+ export function HasExplicitTermStart(stored) {
127
+ /**
128
+ * An UNPARSEABLE value is not an explicit term start.
129
+ *
130
+ * `!!new Date('nonsense')` is true, and the formatter applies `getUTCFullYear()` with no NaN guard,
131
+ * so the field rendered "NaN-NaN-NaN" — which `<input type="date">` rejects and shows blank. The
132
+ * app then believed the line carried a deliberate term start: reset button shown, hint suppressed,
133
+ * and no fallback to the order date.
134
+ */
135
+ return !IsEmptyDateLike(stored);
136
+ }
137
+ //# sourceMappingURL=term-start.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"term-start.js","sourceRoot":"","sources":["../src/term-start.ts"],"names":[],"mappings":"AAiCA;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,qBAAqB,CACjC,OAAqE;IAErE,OAAO,CAAC,CAAC,OAAO,IAAI,MAAM,CAAC,OAAO,CAAC,kBAAkB,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;AACnF,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,UAAU,oBAAoB,CAChC,UAAmB,EACnB,OAAqE,EACrE,MAAgB;IAEhB,IAAI,CAAC,UAAU,EAAE,CAAC;QACd,OAAO,KAAK,CAAC;IACjB,CAAC;IACD;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,IAAI,oBAAoB,CAAC,MAAM,CAAC,EAAE,CAAC;QAC/B,OAAO,IAAI,CAAC;IAChB,CAAC;IACD,OAAO,OAAO,CAAC,CAAC,CAAC,qBAAqB,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;AAC5D,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,eAAe,CAAC,KAAe;IACpC,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;QACxC,OAAO,IAAI,CAAC;IAChB,CAAC;IACD,IAAI,KAAK,YAAY,IAAI,EAAE,CAAC;QACxB,OAAO,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC;IACzC,CAAC;IACD,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC,CAAC;AAC7C,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,UAAU,kBAAkB,CAAC,MAAgB,EAAE,SAAmB;IACpE,OAAO,eAAe,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;AAClE,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,oBAAoB,CAAC,MAAgB;IACjD;;;;;;;OAOG;IACH,OAAO,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC;AACpC,CAAC"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mj-biz-apps/sales-entities",
3
3
  "type": "module",
4
- "version": "6.2.0",
4
+ "version": "6.3.0",
5
5
  "description": "MJ BizApps Sales Entity Subclasses",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",