@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.
Files changed (33) hide show
  1. package/dist/CheckCoverageOverlapOperation.d.ts +28 -0
  2. package/dist/CheckCoverageOverlapOperation.d.ts.map +1 -0
  3. package/dist/CheckCoverageOverlapOperation.js +70 -0
  4. package/dist/CheckCoverageOverlapOperation.js.map +1 -0
  5. package/dist/DetectOverlappingSubscriptionsOperation.d.ts +13 -2
  6. package/dist/DetectOverlappingSubscriptionsOperation.d.ts.map +1 -1
  7. package/dist/DetectOverlappingSubscriptionsOperation.js +17 -3
  8. package/dist/DetectOverlappingSubscriptionsOperation.js.map +1 -1
  9. package/dist/OrderEntityServer.d.ts +71 -3
  10. package/dist/OrderEntityServer.d.ts.map +1 -1
  11. package/dist/OrderEntityServer.js +262 -26
  12. package/dist/OrderEntityServer.js.map +1 -1
  13. package/dist/SpawnRenewalsOperation.d.ts +50 -1
  14. package/dist/SpawnRenewalsOperation.d.ts.map +1 -1
  15. package/dist/SpawnRenewalsOperation.js +210 -19
  16. package/dist/SpawnRenewalsOperation.js.map +1 -1
  17. package/dist/SubscriptionBehavior.d.ts +125 -0
  18. package/dist/SubscriptionBehavior.d.ts.map +1 -1
  19. package/dist/SubscriptionBehavior.js +147 -5
  20. package/dist/SubscriptionBehavior.js.map +1 -1
  21. package/dist/SubscriptionFamilyRules.d.ts +34 -0
  22. package/dist/SubscriptionFamilyRules.d.ts.map +1 -0
  23. package/dist/SubscriptionFamilyRules.js +96 -0
  24. package/dist/SubscriptionFamilyRules.js.map +1 -0
  25. package/dist/entity-names.d.ts +2 -0
  26. package/dist/entity-names.d.ts.map +1 -1
  27. package/dist/entity-names.js +2 -0
  28. package/dist/entity-names.js.map +1 -1
  29. package/dist/index.d.ts +2 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +2 -0
  32. package/dist/index.js.map +1 -1
  33. 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
- PriceStated: line.IsSaved || line.GetFieldByName('UnitPrice')?.Dirty === true || (line.UnitPrice ?? 0) > 0,
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: true,
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
- (await this.findExistingSubscription(product.ID, identity)));
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, identity) {
2819
- // Match on exactly the axes the BenefitModel says define a duplicate. An org-members type
2820
- // ignores the person entirely (one company membership, however many employees); a seat type
2821
- // matches BOTH, so two seats for two people never collide.
2822
- const clauses = [];
2823
- clauses.push(identity.OrganizationID
2824
- ? `HolderOrganizationID='${identity.OrganizationID}'`
2825
- : `HolderOrganizationID IS NULL`);
2826
- clauses.push(identity.PersonID ? `BeneficiaryPersonID='${identity.PersonID}'` : `BeneficiaryPersonID IS NULL`);
2827
- if (!identity.OrganizationID && !identity.PersonID)
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
- return this.loadSubscriptionState(`ProductID='${productID}' AND ${clauses.join(' AND ')}`);
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
- /** Load a subscription plus the end and number of its latest term, by whatever filter. */
2832
- async loadSubscriptionState(filter) {
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
- const sub = res?.Results?.[0];
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
- /** Extension or reactivation — the term is what changes; the subscription just re-activates. */
2936
- async touchExistingSubscription(decision, isRenewal, options, displacedStart) {
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;