@mj-biz-apps/orders-core-entities-server 5.26.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/EntitlementBehavior.d.ts +4 -3
- package/dist/EntitlementBehavior.d.ts.map +1 -1
- package/dist/EntitlementBehavior.js +4 -3
- package/dist/EntitlementBehavior.js.map +1 -1
- package/dist/ExternalInvoiceBehavior.d.ts +12 -8
- package/dist/ExternalInvoiceBehavior.d.ts.map +1 -1
- package/dist/ExternalInvoiceBehavior.js +4 -4
- package/dist/ExternalInvoiceBehavior.js.map +1 -1
- package/dist/GetExternalInvoicingWorklistOperation.d.ts +6 -3
- package/dist/GetExternalInvoicingWorklistOperation.d.ts.map +1 -1
- package/dist/GetExternalInvoicingWorklistOperation.js +44 -44
- package/dist/GetExternalInvoicingWorklistOperation.js.map +1 -1
- package/dist/InvoiceBehavior.d.ts +38 -26
- package/dist/InvoiceBehavior.d.ts.map +1 -1
- package/dist/InvoiceBehavior.js +40 -21
- package/dist/InvoiceBehavior.js.map +1 -1
- package/dist/InvoiceBuilder.d.ts +6 -4
- package/dist/InvoiceBuilder.d.ts.map +1 -1
- package/dist/InvoiceBuilder.js +9 -6
- package/dist/InvoiceBuilder.js.map +1 -1
- package/dist/IssueExternalInvoiceOperation.d.ts +15 -20
- package/dist/IssueExternalInvoiceOperation.d.ts.map +1 -1
- package/dist/IssueExternalInvoiceOperation.js +45 -49
- package/dist/IssueExternalInvoiceOperation.js.map +1 -1
- package/dist/IssueInstalmentInvoiceOperation.d.ts +6 -0
- package/dist/IssueInstalmentInvoiceOperation.d.ts.map +1 -1
- package/dist/IssueInstalmentInvoiceOperation.js +78 -44
- package/dist/IssueInstalmentInvoiceOperation.js.map +1 -1
- package/dist/OrderEntityServer.d.ts +71 -3
- package/dist/OrderEntityServer.d.ts.map +1 -1
- package/dist/OrderEntityServer.js +277 -30
- package/dist/OrderEntityServer.js.map +1 -1
- package/dist/OrderHeaderPaymentScheduleEntityServer.d.ts +7 -6
- package/dist/OrderHeaderPaymentScheduleEntityServer.d.ts.map +1 -1
- package/dist/OrderHeaderPaymentScheduleEntityServer.js +18 -31
- package/dist/OrderHeaderPaymentScheduleEntityServer.js.map +1 -1
- package/dist/OrderJournalEntryFactory.d.ts.map +1 -1
- package/dist/OrderJournalEntryFactory.js +17 -11
- package/dist/OrderJournalEntryFactory.js.map +1 -1
- package/dist/OrderLineEntityServer.d.ts +6 -0
- package/dist/OrderLineEntityServer.d.ts.map +1 -1
- package/dist/OrderLineEntityServer.js +15 -4
- package/dist/OrderLineEntityServer.js.map +1 -1
- package/dist/PaymentAllocationInputs.d.ts +8 -2
- package/dist/PaymentAllocationInputs.d.ts.map +1 -1
- package/dist/PaymentAllocationInputs.js +26 -5
- package/dist/PaymentAllocationInputs.js.map +1 -1
- package/dist/PaymentHeaderEntityServer.d.ts +7 -0
- package/dist/PaymentHeaderEntityServer.d.ts.map +1 -1
- package/dist/PaymentHeaderEntityServer.js +14 -3
- package/dist/PaymentHeaderEntityServer.js.map +1 -1
- package/dist/PaymentScheduleBehavior.d.ts +74 -21
- package/dist/PaymentScheduleBehavior.d.ts.map +1 -1
- package/dist/PaymentScheduleBehavior.js +155 -38
- package/dist/PaymentScheduleBehavior.js.map +1 -1
- package/dist/RecordProgressOperation.d.ts.map +1 -1
- package/dist/RecordProgressOperation.js +3 -5
- package/dist/RecordProgressOperation.js.map +1 -1
- package/dist/ReversalResolver.d.ts.map +1 -1
- package/dist/ReversalResolver.js +6 -1
- package/dist/ReversalResolver.js.map +1 -1
- package/dist/SpawnRenewalsOperation.d.ts +51 -2
- package/dist/SpawnRenewalsOperation.d.ts.map +1 -1
- package/dist/SpawnRenewalsOperation.js +214 -23
- 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 +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- 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.
|
|
@@ -470,6 +495,11 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
470
495
|
await this.Lines.Load();
|
|
471
496
|
}
|
|
472
497
|
await this.expandBundles();
|
|
498
|
+
// EVERY LINE'S PRODUCT IN THE CATALOG CACHE, reloaded once if any is missing (golive #301).
|
|
499
|
+
// Subscription decisions, the company stamp, dimension tags and booking all read the
|
|
500
|
+
// product from `OrdersEngine`; a product written outside this process since it started
|
|
501
|
+
// was invisible to all of them. One reload here serves the whole save.
|
|
502
|
+
await OrdersEngine.Instance.EnsureProducts(this.Lines.Items.map((l) => l.ProductID), this.ContextCurrentUser, this.ProviderToUse);
|
|
473
503
|
const decisions = booking ? await this.decideSubscriptions() : new Map();
|
|
474
504
|
// Ahead of pricing, so an ordinary line on a reversal order is taxed at the address the
|
|
475
505
|
// order will show. Reversal lines take their tax from their origin and ignore it.
|
|
@@ -811,7 +841,9 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
811
841
|
Quantity: line.Quantity,
|
|
812
842
|
UnitPrice: line.UnitPrice,
|
|
813
843
|
ProductPriceID: line.ProductPriceID,
|
|
814
|
-
|
|
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),
|
|
815
847
|
LineTotalNet: line.IsRollupParent ? 0 : this.pendingLineNet(line),
|
|
816
848
|
}));
|
|
817
849
|
const problems = await FindUnapprovedConcessions(this.IsSaved ? this.ID : null, lines, true, this.ProviderToUse, user);
|
|
@@ -847,7 +879,7 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
847
879
|
Quantity: line.Quantity,
|
|
848
880
|
UnitPrice: line.UnitPrice,
|
|
849
881
|
ProductPriceID: line.ProductPriceID,
|
|
850
|
-
PriceStated:
|
|
882
|
+
PriceStated: !this.isUneditedRenewalPrice(line),
|
|
851
883
|
CompanyID: line.CompanyID,
|
|
852
884
|
})),
|
|
853
885
|
BusinessDay: () => BusinessDay(provider, user),
|
|
@@ -2378,7 +2410,7 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
2378
2410
|
}
|
|
2379
2411
|
const subscriptionID = decision.Action === 'CreateNew'
|
|
2380
2412
|
? await this.createSubscription(line, product, rules, decision, decided.Subscriber, options)
|
|
2381
|
-
: 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);
|
|
2382
2414
|
// Remember it so a later line for the same subscription resolves above.
|
|
2383
2415
|
if (decision.Action === 'CreateNew')
|
|
2384
2416
|
createdByDedupeKey.set(decided.DedupeKey, subscriptionID);
|
|
@@ -2458,14 +2490,32 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
2458
2490
|
}
|
|
2459
2491
|
return out;
|
|
2460
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
|
+
}
|
|
2461
2507
|
/**
|
|
2462
2508
|
* Evaluate the subscription rules for every pending line, BEFORE any of them is inserted.
|
|
2463
2509
|
*
|
|
2464
2510
|
* Pure with respect to this app's tables — it reads the catalog and any existing subscription,
|
|
2465
2511
|
* then asks the behaviour what to do. Nothing is written, so a rules rejection aborts the
|
|
2466
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.
|
|
2467
2517
|
*/
|
|
2468
|
-
async decideSubscriptions() {
|
|
2518
|
+
async decideSubscriptions(preview) {
|
|
2469
2519
|
const out = new Map();
|
|
2470
2520
|
const subLines = await this.subscriptionLines([...this.Lines.Items]);
|
|
2471
2521
|
if (subLines.length === 0)
|
|
@@ -2478,6 +2528,10 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
2478
2528
|
// ever see the database, so two lines for one subscription both decide
|
|
2479
2529
|
// "create" — see PENDING_SIBLING_ID.
|
|
2480
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));
|
|
2481
2535
|
// The order's booking day, as a calendar day (#209). `Decide` reduces it with `utcDay` and
|
|
2482
2536
|
// the settled term reaches `SubscriptionTerm.StartDate`/`EndDate`, both `DATE NOT NULL`, so
|
|
2483
2537
|
// an instant taken at 9 PM Eastern would start coverage tomorrow.
|
|
@@ -2493,13 +2547,15 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
2493
2547
|
const behavior = this.behaviorFor(rules);
|
|
2494
2548
|
let subscriber = await this.withInferredOrganization(this.resolveSubscriber(line));
|
|
2495
2549
|
const identity = behavior.DedupeIdentity(rules, subscriber);
|
|
2550
|
+
const match = behavior.DedupeMatch(rules, subscriber);
|
|
2496
2551
|
const dedupeKey = `${product.ID}|${identity.OrganizationID ?? ''}|${identity.PersonID ?? ''}`;
|
|
2497
2552
|
// An explicitly named subscription wins; then a sibling line of THIS order;
|
|
2498
2553
|
// then one already in the database for this subscriber and product (D62).
|
|
2499
2554
|
const existing = line.RenewsSubscriptionID
|
|
2500
2555
|
? await this.loadSubscriptionState(`ID='${line.RenewsSubscriptionID}'`)
|
|
2501
2556
|
: (pendingSiblings.get(dedupeKey) ??
|
|
2502
|
-
(
|
|
2557
|
+
(match.OrNoPerson ? pendingSiblings.get(`${product.ID}|${identity.OrganizationID}|`) : undefined) ??
|
|
2558
|
+
(await this.findExistingSubscription(product.ID, match)));
|
|
2503
2559
|
// NAMING a subscription IS the statement of who the subscriber is. Requiring the line to
|
|
2504
2560
|
// restate it would make renewing a seat impossible without repeating the person, and any
|
|
2505
2561
|
// mismatch between the two would be a silent contradiction. The target wins.
|
|
@@ -2537,16 +2593,61 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
2537
2593
|
RequestedAction: line.SubscriptionAction,
|
|
2538
2594
|
});
|
|
2539
2595
|
if (decision.Action === 'Reject') {
|
|
2596
|
+
if (preview)
|
|
2597
|
+
continue;
|
|
2540
2598
|
// A rules violation fails the WHOLE confirm — booking is all-or-none, and a
|
|
2541
2599
|
// silently-dropped subscription would leave a paid-for line with no coverage.
|
|
2542
2600
|
throw new Error(`Order line ${line.LineNumber} (${product.Name}) cannot be subscribed: ${decision.RejectReason}`);
|
|
2543
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
|
+
}
|
|
2544
2645
|
// A stated start that existing coverage displaced. NOT an error: extending a
|
|
2545
2646
|
// subscription mid-term and stating where the new term begins are both reasonable,
|
|
2546
2647
|
// and only one of them can win — coverage cannot overlap or gap. Reported rather than
|
|
2547
2648
|
// swallowed so the date's disappearance is traceable to a rule instead of looking
|
|
2548
2649
|
// like the field was never saved.
|
|
2549
|
-
if (decision.StartOverrideIgnored && decision.Term && requestedStart) {
|
|
2650
|
+
if (!preview && decision.StartOverrideIgnored && decision.Term && requestedStart) {
|
|
2550
2651
|
LogStatus(`Order ${this.OrderNumber ?? this.ID} line ${line.LineNumber} (${product.Name}): the term start ` +
|
|
2551
2652
|
`${requestedStart.toISOString().slice(0, 10)} stated on the line was not used. This line ` +
|
|
2552
2653
|
`extends an existing subscription, so its term begins the day after current coverage ends — ` +
|
|
@@ -2621,7 +2722,7 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
2621
2722
|
// would be filed against the wrong organization on the order.
|
|
2622
2723
|
const asOf = await CalendarDayOrToday(this.OrderDate, this.ProviderToUse, this.ContextCurrentUser);
|
|
2623
2724
|
const inferred = await this.organizationAsOf(subscriber.PersonID, asOf);
|
|
2624
|
-
return inferred ? { ...subscriber, OrganizationID: inferred } : subscriber;
|
|
2725
|
+
return inferred ? { ...subscriber, OrganizationID: inferred, OrganizationInferred: true } : subscriber;
|
|
2625
2726
|
}
|
|
2626
2727
|
/**
|
|
2627
2728
|
* The organization a person belonged to AS OF the order date (D64).
|
|
@@ -2673,18 +2774,26 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
2673
2774
|
return [];
|
|
2674
2775
|
const provider = this.ProviderToUse;
|
|
2675
2776
|
const user = this.ContextCurrentUser;
|
|
2676
|
-
await
|
|
2777
|
+
await OrdersEngine.Instance.EnsureProducts(lines.map((l) => l.ProductID), user, provider);
|
|
2677
2778
|
const products = new Map();
|
|
2678
2779
|
for (const line of lines) {
|
|
2679
|
-
|
|
2680
|
-
if (!p)
|
|
2780
|
+
if (!line.ProductID)
|
|
2681
2781
|
continue;
|
|
2782
|
+
const p = OrdersEngine.Instance.ProductByID(line.ProductID);
|
|
2783
|
+
// Not skipped: a skipped line was silently treated as no subscription, so it got no term
|
|
2784
|
+
// and no service period (golive #301).
|
|
2785
|
+
if (!p) {
|
|
2786
|
+
throw new Error(`Order line ${line.LineNumber}: product ${line.ProductID} was not found in the product catalog, ` +
|
|
2787
|
+
`even after reloading it from the database.`);
|
|
2788
|
+
}
|
|
2682
2789
|
products.set(uuidKey(p.ID), {
|
|
2683
2790
|
ID: p.ID,
|
|
2684
2791
|
Name: p.Name,
|
|
2685
2792
|
SubscriptionTypeID: p.SubscriptionTypeID,
|
|
2686
2793
|
ProductTypeID: p.ProductTypeID,
|
|
2687
2794
|
RevenueRecognitionTypeID: p.RevenueRecognitionTypeID,
|
|
2795
|
+
CompanyID: p.CompanyID,
|
|
2796
|
+
SubscriptionFamilyID: p.SubscriptionFamilyID,
|
|
2688
2797
|
});
|
|
2689
2798
|
}
|
|
2690
2799
|
if (products.size === 0)
|
|
@@ -2804,32 +2913,155 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
2804
2913
|
* would be a guess, and would pick the wrong one whenever a customer holds two subscriptions to
|
|
2805
2914
|
* the same product under an `AllowMultiple` type.
|
|
2806
2915
|
*/
|
|
2807
|
-
async findExistingSubscription(productID,
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
// matches BOTH, so two seats for two people never collide.
|
|
2811
|
-
const clauses = [];
|
|
2812
|
-
clauses.push(identity.OrganizationID
|
|
2813
|
-
? `HolderOrganizationID='${identity.OrganizationID}'`
|
|
2814
|
-
: `HolderOrganizationID IS NULL`);
|
|
2815
|
-
clauses.push(identity.PersonID ? `BeneficiaryPersonID='${identity.PersonID}'` : `BeneficiaryPersonID IS NULL`);
|
|
2816
|
-
if (!identity.OrganizationID && !identity.PersonID)
|
|
2916
|
+
async findExistingSubscription(productID, match) {
|
|
2917
|
+
const holder = this.holderFilter(match);
|
|
2918
|
+
if (!holder)
|
|
2817
2919
|
return null;
|
|
2818
|
-
|
|
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)
|
|
2933
|
+
return null;
|
|
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;
|
|
2819
3042
|
}
|
|
2820
|
-
/**
|
|
2821
|
-
|
|
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) {
|
|
2822
3050
|
const rv = new RunView(this.ProviderToUse);
|
|
2823
3051
|
const res = await rv.RunView({
|
|
2824
3052
|
EntityName: SUBSCRIPTION_ENTITY,
|
|
2825
3053
|
ExtraFilter: filter,
|
|
2826
3054
|
Fields: ['ID', 'Status', 'HolderOrganizationID', 'BeneficiaryPersonID'],
|
|
2827
3055
|
OrderBy: '__mj_CreatedAt DESC',
|
|
2828
|
-
MaxRows: 1,
|
|
2829
3056
|
ResultType: 'simple',
|
|
2830
3057
|
BypassCache: true,
|
|
2831
3058
|
}, this.ContextCurrentUser);
|
|
2832
|
-
|
|
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];
|
|
2833
3065
|
if (!sub)
|
|
2834
3066
|
return null;
|
|
2835
3067
|
const terms = await rv.RunView({
|
|
@@ -2921,10 +3153,25 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
2921
3153
|
`${event.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
2922
3154
|
}
|
|
2923
3155
|
}
|
|
2924
|
-
/**
|
|
2925
|
-
|
|
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) {
|
|
2926
3165
|
const provider = this.ProviderToUse;
|
|
2927
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
|
+
}
|
|
2928
3175
|
if (decision.Action === 'Reactivate') {
|
|
2929
3176
|
sub.Status = 'Active';
|
|
2930
3177
|
sub.CanceledAt = null;
|
|
@@ -3160,7 +3407,7 @@ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
|
|
|
3160
3407
|
if (!rows.Success) {
|
|
3161
3408
|
throw new Error(`Could not read the payment schedule for order ${this.OrderNumber}: ${rows.ErrorMessage ?? 'unknown error'}`);
|
|
3162
3409
|
}
|
|
3163
|
-
const shortfalls = ScheduleShortfalls(rows.Results ?? [], lines.map((l) => ({ CompanyID: String(l.CompanyID), LineTotalGross: Number(l.LineTotalGross ?? 0) })));
|
|
3410
|
+
const shortfalls = ScheduleShortfalls(rows.Results ?? [], lines.map((l) => ({ CompanyID: String(l.CompanyID), LineTotalGross: Number(l.LineTotalGross ?? 0) })), String(this.CompanyID ?? ''));
|
|
3164
3411
|
if (!shortfalls.length)
|
|
3165
3412
|
return rows.Results ?? [];
|
|
3166
3413
|
const names = new Map((rows.Results ?? []).map((r) => [String(r.CompanyID).toLowerCase(), r.Company ?? r.CompanyID]));
|