@mj-biz-apps/orders-core-entities-server 5.27.0 → 5.28.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/CheckCoverageOverlapOperation.d.ts +28 -0
- package/dist/CheckCoverageOverlapOperation.d.ts.map +1 -0
- package/dist/CheckCoverageOverlapOperation.js +70 -0
- package/dist/CheckCoverageOverlapOperation.js.map +1 -0
- package/dist/DetectOverlappingSubscriptionsOperation.d.ts +13 -2
- package/dist/DetectOverlappingSubscriptionsOperation.d.ts.map +1 -1
- package/dist/DetectOverlappingSubscriptionsOperation.js +17 -3
- package/dist/DetectOverlappingSubscriptionsOperation.js.map +1 -1
- package/dist/OrderEntityServer.d.ts +71 -3
- package/dist/OrderEntityServer.d.ts.map +1 -1
- package/dist/OrderEntityServer.js +262 -26
- package/dist/OrderEntityServer.js.map +1 -1
- package/dist/SpawnRenewalsOperation.d.ts +50 -1
- package/dist/SpawnRenewalsOperation.d.ts.map +1 -1
- package/dist/SpawnRenewalsOperation.js +210 -19
- package/dist/SpawnRenewalsOperation.js.map +1 -1
- package/dist/SubscriptionBehavior.d.ts +125 -0
- package/dist/SubscriptionBehavior.d.ts.map +1 -1
- package/dist/SubscriptionBehavior.js +147 -5
- package/dist/SubscriptionBehavior.js.map +1 -1
- package/dist/SubscriptionFamilyRules.d.ts +34 -0
- package/dist/SubscriptionFamilyRules.d.ts.map +1 -0
- package/dist/SubscriptionFamilyRules.js +96 -0
- package/dist/SubscriptionFamilyRules.js.map +1 -0
- package/dist/entity-names.d.ts +2 -0
- package/dist/entity-names.d.ts.map +1 -1
- package/dist/entity-names.js +2 -0
- package/dist/entity-names.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +13 -13
|
@@ -61,12 +61,12 @@ import { ResolveDueDate } from './PaymentTermsBehavior.js';
|
|
|
61
61
|
import { PaymentTermsChangeGranted } from './PaymentTermsSanction.js';
|
|
62
62
|
import { ExplainShortfalls, ScheduleShortfalls } from './PaymentScheduleBehavior.js';
|
|
63
63
|
import { IssueInstalment } from './IssueInstalmentInvoiceOperation.js';
|
|
64
|
-
import { ORDER_HEADER_PAYMENT_SCHEDULE_ENTITY } from './entity-names.js';
|
|
64
|
+
import { ORDER_HEADER_PAYMENT_SCHEDULE_ENTITY, SUBSCRIPTION_FAMILY_ENTITY } from './entity-names.js';
|
|
65
65
|
import { CheckOrderBillToName, LoadBillToName } from './RailCustomerNameLimit.js';
|
|
66
66
|
import { Today, LineGross, LoadOrdersEngine, NetAfterDiscount, OrderPricingService, OrdersEngine, WriteAdjustments, WriteCharges } from '@mj-biz-apps/orders-entities';
|
|
67
67
|
const CUSTOMER_PAYMENT_TERMS_ENTITY = 'MJ_BizApps_Orders: Customer Payment Terms';
|
|
68
68
|
const ORDER_COMPANY_POLICY_ENTITY = 'MJ_BizApps_Orders: Order Company Policies';
|
|
69
|
-
import { SubscriptionBehavior, ResolveSubscriptionTypeID, SubscriptionTypeRulesFrom, } from './SubscriptionBehavior.js';
|
|
69
|
+
import { SubscriptionBehavior, ResolveSubscriptionTypeID, SubscriptionTypeRulesFrom, OverlappingCoverage, } from './SubscriptionBehavior.js';
|
|
70
70
|
const ORDER_ENTITY = 'MJ_BizApps_Orders: Order Headers';
|
|
71
71
|
const CHARGE_TYPE_ENTITY = 'MJ_BizApps_Orders: Charge Types';
|
|
72
72
|
// bizapps-common names its entities with DOTS, not the underscores the other apps use.
|
|
@@ -114,6 +114,8 @@ const DEPENDENT_ROW_CAP = 1000;
|
|
|
114
114
|
* same failure shape: a silent miss that looks like missing data rather than a key mismatch.
|
|
115
115
|
*/
|
|
116
116
|
const uuidKey = (id) => (id ?? '').toLowerCase();
|
|
117
|
+
/** How live a subscription is, lowest first: the order a re-purchase prefers among several. */
|
|
118
|
+
const subscriptionStatusRank = (status) => status === 'Active' || status === 'Trialing' ? 0 : status === 'Paused' ? 1 : status === 'Canceled' ? 2 : 3;
|
|
117
119
|
/**
|
|
118
120
|
* Stands in for a subscription an EARLIER LINE OF THIS ORDER is about to create.
|
|
119
121
|
*
|
|
@@ -131,6 +133,8 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
131
133
|
super(...arguments);
|
|
132
134
|
/** Price decompositions produced during this save, written once the lines have IDs (D69). */
|
|
133
135
|
this._priceComponents = new Map();
|
|
136
|
+
/** Lines the renewal pass priced, with the price it wrote; see {@link MarkRenewalPriced}. */
|
|
137
|
+
this._renewalPricedLines = new Map();
|
|
134
138
|
/** Why a line owes no tax, by line index — written as a zero-amount component (D73). */
|
|
135
139
|
this._taxReasons = new Map();
|
|
136
140
|
this._manualDiscounts = [];
|
|
@@ -174,6 +178,27 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
174
178
|
PaymentTermsChangeSanctioned() {
|
|
175
179
|
return PaymentTermsChangeGranted(this);
|
|
176
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Record that the renewal pass priced this line (golive #304).
|
|
183
|
+
*
|
|
184
|
+
* A renewal is priced from the prior term, not today's list: a lapsed discount, a grandfathered
|
|
185
|
+
* price raised by the annual increase. That can sit below the engine's price, and it is the
|
|
186
|
+
* price finance chose, not a concession someone typed. While the line still carries the price
|
|
187
|
+
* the pass wrote, the concession gate and the below-engine review treat it as engine-priced.
|
|
188
|
+
* Change the price and both judge it as usual.
|
|
189
|
+
*/
|
|
190
|
+
MarkRenewalPriced(line) {
|
|
191
|
+
this._renewalPricedLines.set(line, Number(line.UnitPrice));
|
|
192
|
+
}
|
|
193
|
+
/** True for a line the renewal pass priced whose price is unchanged; matched by entity or saved ID. */
|
|
194
|
+
isUneditedRenewalPrice(line) {
|
|
195
|
+
for (const [marked, price] of this._renewalPricedLines) {
|
|
196
|
+
const same = marked === line || (!!line.ID && marked.IsSaved && marked.ID.toLowerCase() === String(line.ID).toLowerCase());
|
|
197
|
+
if (same)
|
|
198
|
+
return Math.abs(Number(line.UnitPrice ?? 0) - price) < 0.005;
|
|
199
|
+
}
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
177
202
|
// `PromotionCodes` is not declared here any more. It is a COMPANION on the shared subclass, so
|
|
178
203
|
// the browser has it too — which is the entire point: a code typed on screen used to be priced
|
|
179
204
|
// into the preview and then dropped at confirm, because only the server could hold one.
|
|
@@ -816,7 +841,9 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
816
841
|
Quantity: line.Quantity,
|
|
817
842
|
UnitPrice: line.UnitPrice,
|
|
818
843
|
ProductPriceID: line.ProductPriceID,
|
|
819
|
-
|
|
844
|
+
// A renewal line's price is the renewal pass's own decision (golive #304), not a stated one.
|
|
845
|
+
PriceStated: !this.isUneditedRenewalPrice(line) &&
|
|
846
|
+
(line.IsSaved || line.GetFieldByName('UnitPrice')?.Dirty === true || (line.UnitPrice ?? 0) > 0),
|
|
820
847
|
LineTotalNet: line.IsRollupParent ? 0 : this.pendingLineNet(line),
|
|
821
848
|
}));
|
|
822
849
|
const problems = await FindUnapprovedConcessions(this.IsSaved ? this.ID : null, lines, true, this.ProviderToUse, user);
|
|
@@ -852,7 +879,7 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
852
879
|
Quantity: line.Quantity,
|
|
853
880
|
UnitPrice: line.UnitPrice,
|
|
854
881
|
ProductPriceID: line.ProductPriceID,
|
|
855
|
-
PriceStated:
|
|
882
|
+
PriceStated: !this.isUneditedRenewalPrice(line),
|
|
856
883
|
CompanyID: line.CompanyID,
|
|
857
884
|
})),
|
|
858
885
|
BusinessDay: () => BusinessDay(provider, user),
|
|
@@ -2383,7 +2410,7 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
2383
2410
|
}
|
|
2384
2411
|
const subscriptionID = decision.Action === 'CreateNew'
|
|
2385
2412
|
? await this.createSubscription(line, product, rules, decision, decided.Subscriber, options)
|
|
2386
|
-
: await this.touchExistingSubscription(decision, !!line.RenewsSubscriptionID, options, DisplacedStartEventData(line.ID, decided.Decision, decided.RequestedStart));
|
|
2413
|
+
: await this.touchExistingSubscription(decision, !!line.RenewsSubscriptionID, options, DisplacedStartEventData(line.ID, decided.Decision, decided.RequestedStart), line.RenewsSubscriptionID ? { ProductID: product.ID, SubscriptionTypeID: rules.ID } : undefined);
|
|
2387
2414
|
// Remember it so a later line for the same subscription resolves above.
|
|
2388
2415
|
if (decision.Action === 'CreateNew')
|
|
2389
2416
|
createdByDedupeKey.set(decided.DedupeKey, subscriptionID);
|
|
@@ -2463,14 +2490,32 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
2463
2490
|
}
|
|
2464
2491
|
return out;
|
|
2465
2492
|
}
|
|
2493
|
+
/**
|
|
2494
|
+
* The subscription lines of this draft whose terms overlap coverage in their family, and what
|
|
2495
|
+
* confirm will do with each (golive #276). Writes nothing.
|
|
2496
|
+
*
|
|
2497
|
+
* Runs the same decision pass confirm runs, over the SAVED lines. Lines a bundle would add at
|
|
2498
|
+
* confirm are not expanded here, so a band sold inside a bundle is caught at confirm only.
|
|
2499
|
+
*/
|
|
2500
|
+
async PreviewCoverageOverlaps() {
|
|
2501
|
+
if (this.IsSaved && !this.Lines.IsLoaded)
|
|
2502
|
+
await this.Lines.Load();
|
|
2503
|
+
const out = [];
|
|
2504
|
+
await this.decideSubscriptions(out);
|
|
2505
|
+
return out;
|
|
2506
|
+
}
|
|
2466
2507
|
/**
|
|
2467
2508
|
* Evaluate the subscription rules for every pending line, BEFORE any of them is inserted.
|
|
2468
2509
|
*
|
|
2469
2510
|
* Pure with respect to this app's tables — it reads the catalog and any existing subscription,
|
|
2470
2511
|
* then asks the behaviour what to do. Nothing is written, so a rules rejection aborts the
|
|
2471
2512
|
* confirm before a single line exists.
|
|
2513
|
+
*
|
|
2514
|
+
* `preview` turns refusals into answers: a line the rules reject is skipped, and every line
|
|
2515
|
+
* whose term overlaps coverage in its family is collected there instead of throwing. That is
|
|
2516
|
+
* how `Orders.CheckCoverageOverlap` asks the same question confirm does without confirming.
|
|
2472
2517
|
*/
|
|
2473
|
-
async decideSubscriptions() {
|
|
2518
|
+
async decideSubscriptions(preview) {
|
|
2474
2519
|
const out = new Map();
|
|
2475
2520
|
const subLines = await this.subscriptionLines([...this.Lines.Items]);
|
|
2476
2521
|
if (subLines.length === 0)
|
|
@@ -2483,6 +2528,10 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
2483
2528
|
// ever see the database, so two lines for one subscription both decide
|
|
2484
2529
|
// "create" — see PENDING_SIBLING_ID.
|
|
2485
2530
|
const pendingSiblings = new Map();
|
|
2531
|
+
// The same, for other BANDS: terms earlier lines of this order will create, keyed by
|
|
2532
|
+
// family and holder, so two bands on one order are checked against each other too.
|
|
2533
|
+
const pendingFamily = new Map();
|
|
2534
|
+
const familyNames = await this.loadFamilyNames(subLines.map((s) => s.product.SubscriptionFamilyID));
|
|
2486
2535
|
// The order's booking day, as a calendar day (#209). `Decide` reduces it with `utcDay` and
|
|
2487
2536
|
// the settled term reaches `SubscriptionTerm.StartDate`/`EndDate`, both `DATE NOT NULL`, so
|
|
2488
2537
|
// an instant taken at 9 PM Eastern would start coverage tomorrow.
|
|
@@ -2498,13 +2547,15 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
2498
2547
|
const behavior = this.behaviorFor(rules);
|
|
2499
2548
|
let subscriber = await this.withInferredOrganization(this.resolveSubscriber(line));
|
|
2500
2549
|
const identity = behavior.DedupeIdentity(rules, subscriber);
|
|
2550
|
+
const match = behavior.DedupeMatch(rules, subscriber);
|
|
2501
2551
|
const dedupeKey = `${product.ID}|${identity.OrganizationID ?? ''}|${identity.PersonID ?? ''}`;
|
|
2502
2552
|
// An explicitly named subscription wins; then a sibling line of THIS order;
|
|
2503
2553
|
// then one already in the database for this subscriber and product (D62).
|
|
2504
2554
|
const existing = line.RenewsSubscriptionID
|
|
2505
2555
|
? await this.loadSubscriptionState(`ID='${line.RenewsSubscriptionID}'`)
|
|
2506
2556
|
: (pendingSiblings.get(dedupeKey) ??
|
|
2507
|
-
(
|
|
2557
|
+
(match.OrNoPerson ? pendingSiblings.get(`${product.ID}|${identity.OrganizationID}|`) : undefined) ??
|
|
2558
|
+
(await this.findExistingSubscription(product.ID, match)));
|
|
2508
2559
|
// NAMING a subscription IS the statement of who the subscriber is. Requiring the line to
|
|
2509
2560
|
// restate it would make renewing a seat impossible without repeating the person, and any
|
|
2510
2561
|
// mismatch between the two would be a silent contradiction. The target wins.
|
|
@@ -2542,16 +2593,61 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
2542
2593
|
RequestedAction: line.SubscriptionAction,
|
|
2543
2594
|
});
|
|
2544
2595
|
if (decision.Action === 'Reject') {
|
|
2596
|
+
if (preview)
|
|
2597
|
+
continue;
|
|
2545
2598
|
// A rules violation fails the WHOLE confirm — booking is all-or-none, and a
|
|
2546
2599
|
// silently-dropped subscription would leave a paid-for line with no coverage.
|
|
2547
2600
|
throw new Error(`Order line ${line.LineNumber} (${product.Name}) cannot be subscribed: ${decision.RejectReason}`);
|
|
2548
2601
|
}
|
|
2602
|
+
// ANOTHER BAND of the same offering (golive #276). The lookup above matches on the
|
|
2603
|
+
// product, so a holder with coverage under a sibling band found nothing and a second
|
|
2604
|
+
// subscription for the same dates was created and billed. A renewal names its target
|
|
2605
|
+
// outright, so it continues that subscription and is not checked.
|
|
2606
|
+
if (decision.Term && product.SubscriptionFamilyID && !line.RenewsSubscriptionID) {
|
|
2607
|
+
const familyKey = `${uuidKey(product.SubscriptionFamilyID)}|${identity.OrganizationID ?? ''}|${identity.PersonID ?? ''}`;
|
|
2608
|
+
const noPersonKey = `${uuidKey(product.SubscriptionFamilyID)}|${identity.OrganizationID ?? ''}|`;
|
|
2609
|
+
const siblings = [
|
|
2610
|
+
...(pendingFamily.get(familyKey) ?? []),
|
|
2611
|
+
...(match.OrNoPerson ? (pendingFamily.get(noPersonKey) ?? []) : []),
|
|
2612
|
+
].filter((s) => !UUIDsEqual(s.ProductID, product.ID));
|
|
2613
|
+
const overlaps = OverlappingCoverage([...siblings, ...(await this.loadFamilyCoverage(product, match, decision.Term.StartDate))], decision.Term.StartDate, decision.Term.EndDate);
|
|
2614
|
+
const verdict = behavior.DecideCoverageOverlap({
|
|
2615
|
+
Rules: rules,
|
|
2616
|
+
Family: familyNames.get(uuidKey(product.SubscriptionFamilyID)) ?? product.SubscriptionFamilyID,
|
|
2617
|
+
ProductName: product.Name,
|
|
2618
|
+
Overlaps: overlaps,
|
|
2619
|
+
Acknowledged: !!line.AcknowledgesCoverageOverlap,
|
|
2620
|
+
});
|
|
2621
|
+
if (preview) {
|
|
2622
|
+
if (verdict.Outcome !== 'None')
|
|
2623
|
+
preview.push({ Line: line, Decision: verdict, Overlaps: overlaps });
|
|
2624
|
+
}
|
|
2625
|
+
else if (verdict.Outcome === 'Refused' || verdict.Outcome === 'NeedsAck') {
|
|
2626
|
+
throw new Error(`Order line ${line.LineNumber} (${product.Name}) cannot be confirmed: ${verdict.Message}`);
|
|
2627
|
+
}
|
|
2628
|
+
else if (verdict.Outcome !== 'None') {
|
|
2629
|
+
LogStatus(`Order ${this.OrderNumber ?? this.ID} line ${line.LineNumber}: ${verdict.Message}`);
|
|
2630
|
+
}
|
|
2631
|
+
pendingFamily.set(familyKey, [
|
|
2632
|
+
...(pendingFamily.get(familyKey) ?? []),
|
|
2633
|
+
{
|
|
2634
|
+
ProductID: product.ID,
|
|
2635
|
+
SubscriptionID: null,
|
|
2636
|
+
SubscriptionNumber: null,
|
|
2637
|
+
ProductName: product.Name,
|
|
2638
|
+
StartDate: decision.Term.StartDate,
|
|
2639
|
+
EndDate: decision.Term.EndDate,
|
|
2640
|
+
ConcurrencyMode: rules.ConcurrencyMode,
|
|
2641
|
+
SubscriptionTypeCode: rules.Code,
|
|
2642
|
+
},
|
|
2643
|
+
]);
|
|
2644
|
+
}
|
|
2549
2645
|
// A stated start that existing coverage displaced. NOT an error: extending a
|
|
2550
2646
|
// subscription mid-term and stating where the new term begins are both reasonable,
|
|
2551
2647
|
// and only one of them can win — coverage cannot overlap or gap. Reported rather than
|
|
2552
2648
|
// swallowed so the date's disappearance is traceable to a rule instead of looking
|
|
2553
2649
|
// like the field was never saved.
|
|
2554
|
-
if (decision.StartOverrideIgnored && decision.Term && requestedStart) {
|
|
2650
|
+
if (!preview && decision.StartOverrideIgnored && decision.Term && requestedStart) {
|
|
2555
2651
|
LogStatus(`Order ${this.OrderNumber ?? this.ID} line ${line.LineNumber} (${product.Name}): the term start ` +
|
|
2556
2652
|
`${requestedStart.toISOString().slice(0, 10)} stated on the line was not used. This line ` +
|
|
2557
2653
|
`extends an existing subscription, so its term begins the day after current coverage ends — ` +
|
|
@@ -2626,7 +2722,7 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
2626
2722
|
// would be filed against the wrong organization on the order.
|
|
2627
2723
|
const asOf = await CalendarDayOrToday(this.OrderDate, this.ProviderToUse, this.ContextCurrentUser);
|
|
2628
2724
|
const inferred = await this.organizationAsOf(subscriber.PersonID, asOf);
|
|
2629
|
-
return inferred ? { ...subscriber, OrganizationID: inferred } : subscriber;
|
|
2725
|
+
return inferred ? { ...subscriber, OrganizationID: inferred, OrganizationInferred: true } : subscriber;
|
|
2630
2726
|
}
|
|
2631
2727
|
/**
|
|
2632
2728
|
* The organization a person belonged to AS OF the order date (D64).
|
|
@@ -2696,6 +2792,8 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
2696
2792
|
SubscriptionTypeID: p.SubscriptionTypeID,
|
|
2697
2793
|
ProductTypeID: p.ProductTypeID,
|
|
2698
2794
|
RevenueRecognitionTypeID: p.RevenueRecognitionTypeID,
|
|
2795
|
+
CompanyID: p.CompanyID,
|
|
2796
|
+
SubscriptionFamilyID: p.SubscriptionFamilyID,
|
|
2699
2797
|
});
|
|
2700
2798
|
}
|
|
2701
2799
|
if (products.size === 0)
|
|
@@ -2815,32 +2913,155 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
2815
2913
|
* would be a guess, and would pick the wrong one whenever a customer holds two subscriptions to
|
|
2816
2914
|
* the same product under an `AllowMultiple` type.
|
|
2817
2915
|
*/
|
|
2818
|
-
async findExistingSubscription(productID,
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
const
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2916
|
+
async findExistingSubscription(productID, match) {
|
|
2917
|
+
const holder = this.holderFilter(match);
|
|
2918
|
+
if (!holder)
|
|
2919
|
+
return null;
|
|
2920
|
+
const person = match.PersonID === 'Any' ? null : match.PersonID;
|
|
2921
|
+
return this.loadSubscriptionState(`ProductID='${productID}' AND ${holder}`, person);
|
|
2922
|
+
}
|
|
2923
|
+
/**
|
|
2924
|
+
* The subscription filter for a dedupe match, or null when it names nobody.
|
|
2925
|
+
*
|
|
2926
|
+
* Match on exactly the axes the BenefitModel says define a duplicate (`DedupeMatch`). An
|
|
2927
|
+
* org-held type ignores the stored person entirely (one company membership, however many
|
|
2928
|
+
* employees, whoever the order named as its contact); a seat type matches BOTH, so two seats
|
|
2929
|
+
* for two people never collide.
|
|
2930
|
+
*/
|
|
2931
|
+
holderFilter(match) {
|
|
2932
|
+
if (!match.OrganizationID && !match.PersonID)
|
|
2828
2933
|
return null;
|
|
2829
|
-
|
|
2934
|
+
const org = match.OrganizationID
|
|
2935
|
+
? `HolderOrganizationID='${RequireUUID(match.OrganizationID, 'HolderOrganizationID')}'`
|
|
2936
|
+
: `HolderOrganizationID IS NULL`;
|
|
2937
|
+
if (match.PersonID === 'Any')
|
|
2938
|
+
return org;
|
|
2939
|
+
if (!match.PersonID)
|
|
2940
|
+
return `${org} AND BeneficiaryPersonID IS NULL`;
|
|
2941
|
+
const person = `BeneficiaryPersonID='${RequireUUID(match.PersonID, 'BeneficiaryPersonID')}'`;
|
|
2942
|
+
return match.OrNoPerson
|
|
2943
|
+
? `${org} AND (${person} OR BeneficiaryPersonID IS NULL)`
|
|
2944
|
+
: `${org} AND ${person}`;
|
|
2945
|
+
}
|
|
2946
|
+
/** Family names by ID, for the overlap message. One read for every family on the order. */
|
|
2947
|
+
async loadFamilyNames(ids) {
|
|
2948
|
+
const unique = [...new Set(ids.filter((id) => !!id).map(uuidKey))];
|
|
2949
|
+
if (unique.length === 0)
|
|
2950
|
+
return new Map();
|
|
2951
|
+
const rv = new RunView(this.ProviderToUse);
|
|
2952
|
+
const list = RequireUUIDs(unique, 'SubscriptionFamilyID').map((id) => `'${id}'`).join(',');
|
|
2953
|
+
const res = await rv.RunView({
|
|
2954
|
+
EntityName: SUBSCRIPTION_FAMILY_ENTITY,
|
|
2955
|
+
ExtraFilter: `ID IN (${list})`,
|
|
2956
|
+
Fields: ['ID', 'Code', 'Name'],
|
|
2957
|
+
ResultType: 'simple',
|
|
2958
|
+
}, this.ContextCurrentUser);
|
|
2959
|
+
if (!res?.Success)
|
|
2960
|
+
throw new Error(`Could not read subscription families: ${res?.ErrorMessage}`);
|
|
2961
|
+
return new Map(res.Results.map((f) => [uuidKey(f.ID), `${f.Name} (${f.Code})`]));
|
|
2962
|
+
}
|
|
2963
|
+
/**
|
|
2964
|
+
* Terms that cover this holder under OTHER bands of the product's family and end on or after
|
|
2965
|
+
* `start` (golive #276). Terms after `end` are included so the caller can say when coverage
|
|
2966
|
+
* runs out.
|
|
2967
|
+
*
|
|
2968
|
+
* Bands are the family's other products in the product's own company. A subscription counts
|
|
2969
|
+
* unless it is Migrated, and its terms count unless they are Canceled or Lapsed. A Canceled
|
|
2970
|
+
* subscription still counts: cancelling stamps one term and leaves the subscription's access
|
|
2971
|
+
* running to `Subscription.EndDate`, so its Canceled term counts through that date, and any
|
|
2972
|
+
* term it did not stamp counts in full.
|
|
2973
|
+
*/
|
|
2974
|
+
async loadFamilyCoverage(product, match, start) {
|
|
2975
|
+
const holder = this.holderFilter(match);
|
|
2976
|
+
const familyID = product.SubscriptionFamilyID;
|
|
2977
|
+
if (!holder || !familyID)
|
|
2978
|
+
return [];
|
|
2979
|
+
// The engine caches every product, active or not — a discontinued band still has holders.
|
|
2980
|
+
const bands = new Map(OrdersEngine.Instance.Products.filter((p) => UUIDsEqual(p.SubscriptionFamilyID, familyID) &&
|
|
2981
|
+
UUIDsEqual(p.CompanyID, product.CompanyID) &&
|
|
2982
|
+
!UUIDsEqual(p.ID, product.ID)).map((p) => [uuidKey(p.ID), p.Name]));
|
|
2983
|
+
if (bands.size === 0)
|
|
2984
|
+
return [];
|
|
2985
|
+
const rv = new RunView(this.ProviderToUse);
|
|
2986
|
+
const bandIDs = RequireUUIDs([...bands.keys()], 'ProductID').map((id) => `'${id}'`).join(',');
|
|
2987
|
+
const subs = await rv.RunView({
|
|
2988
|
+
EntityName: SUBSCRIPTION_ENTITY,
|
|
2989
|
+
ExtraFilter: `ProductID IN (${bandIDs}) AND ${holder} AND Status <> 'Migrated'`,
|
|
2990
|
+
Fields: ['ID', 'SubscriptionNumber', 'ProductID', 'SubscriptionTypeID', 'Status', 'EndDate'],
|
|
2991
|
+
ResultType: 'simple',
|
|
2992
|
+
BypassCache: true,
|
|
2993
|
+
}, this.ContextCurrentUser);
|
|
2994
|
+
if (!subs?.Success) {
|
|
2995
|
+
throw new Error(`Could not read this holder's other subscriptions in the product's family: ${subs?.ErrorMessage}`);
|
|
2996
|
+
}
|
|
2997
|
+
if (subs.Results.length === 0)
|
|
2998
|
+
return [];
|
|
2999
|
+
const byID = new Map(subs.Results.map((s) => [uuidKey(s.ID), s]));
|
|
3000
|
+
const subIDs = RequireUUIDs(subs.Results.map((s) => s.ID), 'SubscriptionID').map((id) => `'${id}'`).join(',');
|
|
3001
|
+
const day = (d) => d.toISOString().slice(0, 10);
|
|
3002
|
+
const terms = await rv.RunView({
|
|
3003
|
+
EntityName: SUBSCRIPTION_TERM_ENTITY,
|
|
3004
|
+
ExtraFilter: `SubscriptionID IN (${subIDs}) AND Status <> 'Lapsed' AND EndDate >= '${day(start)}'`,
|
|
3005
|
+
Fields: ['SubscriptionID', 'StartDate', 'EndDate', 'Status'],
|
|
3006
|
+
ResultType: 'simple',
|
|
3007
|
+
BypassCache: true,
|
|
3008
|
+
}, this.ContextCurrentUser);
|
|
3009
|
+
if (!terms?.Success) {
|
|
3010
|
+
throw new Error(`Could not read the terms of this holder's other subscriptions in the product's family: ${terms?.ErrorMessage}`);
|
|
3011
|
+
}
|
|
3012
|
+
const out = [];
|
|
3013
|
+
for (const t of terms.Results) {
|
|
3014
|
+
const sub = byID.get(uuidKey(t.SubscriptionID));
|
|
3015
|
+
if (!sub)
|
|
3016
|
+
continue;
|
|
3017
|
+
let endDate = new Date(t.EndDate);
|
|
3018
|
+
if (t.Status === 'Canceled') {
|
|
3019
|
+
// Only a cancelled subscription's own access window keeps a Canceled term alive.
|
|
3020
|
+
if (sub.Status !== 'Canceled' || !sub.EndDate)
|
|
3021
|
+
continue;
|
|
3022
|
+
const accessEnd = new Date(sub.EndDate);
|
|
3023
|
+
if (accessEnd.getTime() < endDate.getTime())
|
|
3024
|
+
endDate = accessEnd;
|
|
3025
|
+
}
|
|
3026
|
+
const type = OrdersEngine.Instance.SubscriptionTypeByID(sub.SubscriptionTypeID);
|
|
3027
|
+
if (!type) {
|
|
3028
|
+
throw new Error(`Subscription ${sub.SubscriptionNumber} names a subscription type that was not found.`);
|
|
3029
|
+
}
|
|
3030
|
+
const rules = SubscriptionTypeRulesFrom(type);
|
|
3031
|
+
out.push({
|
|
3032
|
+
SubscriptionID: t.SubscriptionID,
|
|
3033
|
+
SubscriptionNumber: sub.SubscriptionNumber ?? null,
|
|
3034
|
+
ProductName: bands.get(uuidKey(sub.ProductID)) ?? 'another band',
|
|
3035
|
+
StartDate: new Date(t.StartDate),
|
|
3036
|
+
EndDate: endDate,
|
|
3037
|
+
ConcurrencyMode: rules.ConcurrencyMode,
|
|
3038
|
+
SubscriptionTypeCode: rules.Code,
|
|
3039
|
+
});
|
|
3040
|
+
}
|
|
3041
|
+
return out;
|
|
2830
3042
|
}
|
|
2831
|
-
/**
|
|
2832
|
-
|
|
3043
|
+
/**
|
|
3044
|
+
* Load a subscription plus the end and number of its latest term, by whatever filter.
|
|
3045
|
+
*
|
|
3046
|
+
* When the filter matches several, the most live one wins: Active or Trialing, then Paused,
|
|
3047
|
+
* then Canceled, then the rest; among equals, one stored for `personID`, then the newest.
|
|
3048
|
+
*/
|
|
3049
|
+
async loadSubscriptionState(filter, personID) {
|
|
2833
3050
|
const rv = new RunView(this.ProviderToUse);
|
|
2834
3051
|
const res = await rv.RunView({
|
|
2835
3052
|
EntityName: SUBSCRIPTION_ENTITY,
|
|
2836
3053
|
ExtraFilter: filter,
|
|
2837
3054
|
Fields: ['ID', 'Status', 'HolderOrganizationID', 'BeneficiaryPersonID'],
|
|
2838
3055
|
OrderBy: '__mj_CreatedAt DESC',
|
|
2839
|
-
MaxRows: 1,
|
|
2840
3056
|
ResultType: 'simple',
|
|
2841
3057
|
BypassCache: true,
|
|
2842
3058
|
}, this.ContextCurrentUser);
|
|
2843
|
-
|
|
3059
|
+
// An org-held match ignores the stored person, so an org can match several, and a newer
|
|
3060
|
+
// canceled one must not hide one still running or paused. Rows arrive newest first and the
|
|
3061
|
+
// sort is stable, so the newest wins among equals.
|
|
3062
|
+
const rows = [...(res?.Results ?? [])].sort((a, b) => subscriptionStatusRank(a.Status) - subscriptionStatusRank(b.Status) ||
|
|
3063
|
+
Number(!UUIDsEqual(a.BeneficiaryPersonID, personID)) - Number(!UUIDsEqual(b.BeneficiaryPersonID, personID)));
|
|
3064
|
+
const sub = rows[0];
|
|
2844
3065
|
if (!sub)
|
|
2845
3066
|
return null;
|
|
2846
3067
|
const terms = await rv.RunView({
|
|
@@ -2932,10 +3153,25 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
2932
3153
|
`${event.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
2933
3154
|
}
|
|
2934
3155
|
}
|
|
2935
|
-
/**
|
|
2936
|
-
|
|
3156
|
+
/**
|
|
3157
|
+
* Extension or reactivation — the term is what changes; the subscription just re-activates.
|
|
3158
|
+
*
|
|
3159
|
+
* `renewedOnto` is the product a renewal line names. When it is not the subscription's product,
|
|
3160
|
+
* the subscription moved to a successor at renewal (golive #304), and from this term on it is a
|
|
3161
|
+
* subscription to the successor: its product and type follow, so the next renewal, entitlement
|
|
3162
|
+
* checks and reports all read the product the customer now holds.
|
|
3163
|
+
*/
|
|
3164
|
+
async touchExistingSubscription(decision, isRenewal, options, displacedStart, renewedOnto) {
|
|
2937
3165
|
const provider = this.ProviderToUse;
|
|
2938
3166
|
const sub = await provider.GetEntityObject(SUBSCRIPTION_ENTITY, CompositeKey.FromID(decision.SubscriptionID), this.ContextCurrentUser);
|
|
3167
|
+
if (renewedOnto && sub.ProductID.toLowerCase() !== renewedOnto.ProductID.toLowerCase()) {
|
|
3168
|
+
sub.ProductID = renewedOnto.ProductID;
|
|
3169
|
+
sub.SubscriptionTypeID = renewedOnto.SubscriptionTypeID;
|
|
3170
|
+
if (!(await sub.Save(options))) {
|
|
3171
|
+
throw new Error(`Failed to move subscription ${sub.SubscriptionNumber} onto its successor product: ` +
|
|
3172
|
+
`${sub.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
2939
3175
|
if (decision.Action === 'Reactivate') {
|
|
2940
3176
|
sub.Status = 'Active';
|
|
2941
3177
|
sub.CanceledAt = null;
|