@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.
Files changed (90) 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/EntitlementBehavior.d.ts +4 -3
  10. package/dist/EntitlementBehavior.d.ts.map +1 -1
  11. package/dist/EntitlementBehavior.js +4 -3
  12. package/dist/EntitlementBehavior.js.map +1 -1
  13. package/dist/ExternalInvoiceBehavior.d.ts +12 -8
  14. package/dist/ExternalInvoiceBehavior.d.ts.map +1 -1
  15. package/dist/ExternalInvoiceBehavior.js +4 -4
  16. package/dist/ExternalInvoiceBehavior.js.map +1 -1
  17. package/dist/GetExternalInvoicingWorklistOperation.d.ts +6 -3
  18. package/dist/GetExternalInvoicingWorklistOperation.d.ts.map +1 -1
  19. package/dist/GetExternalInvoicingWorklistOperation.js +44 -44
  20. package/dist/GetExternalInvoicingWorklistOperation.js.map +1 -1
  21. package/dist/InvoiceBehavior.d.ts +38 -26
  22. package/dist/InvoiceBehavior.d.ts.map +1 -1
  23. package/dist/InvoiceBehavior.js +40 -21
  24. package/dist/InvoiceBehavior.js.map +1 -1
  25. package/dist/InvoiceBuilder.d.ts +6 -4
  26. package/dist/InvoiceBuilder.d.ts.map +1 -1
  27. package/dist/InvoiceBuilder.js +9 -6
  28. package/dist/InvoiceBuilder.js.map +1 -1
  29. package/dist/IssueExternalInvoiceOperation.d.ts +15 -20
  30. package/dist/IssueExternalInvoiceOperation.d.ts.map +1 -1
  31. package/dist/IssueExternalInvoiceOperation.js +45 -49
  32. package/dist/IssueExternalInvoiceOperation.js.map +1 -1
  33. package/dist/IssueInstalmentInvoiceOperation.d.ts +6 -0
  34. package/dist/IssueInstalmentInvoiceOperation.d.ts.map +1 -1
  35. package/dist/IssueInstalmentInvoiceOperation.js +78 -44
  36. package/dist/IssueInstalmentInvoiceOperation.js.map +1 -1
  37. package/dist/OrderEntityServer.d.ts +71 -3
  38. package/dist/OrderEntityServer.d.ts.map +1 -1
  39. package/dist/OrderEntityServer.js +277 -30
  40. package/dist/OrderEntityServer.js.map +1 -1
  41. package/dist/OrderHeaderPaymentScheduleEntityServer.d.ts +7 -6
  42. package/dist/OrderHeaderPaymentScheduleEntityServer.d.ts.map +1 -1
  43. package/dist/OrderHeaderPaymentScheduleEntityServer.js +18 -31
  44. package/dist/OrderHeaderPaymentScheduleEntityServer.js.map +1 -1
  45. package/dist/OrderJournalEntryFactory.d.ts.map +1 -1
  46. package/dist/OrderJournalEntryFactory.js +17 -11
  47. package/dist/OrderJournalEntryFactory.js.map +1 -1
  48. package/dist/OrderLineEntityServer.d.ts +6 -0
  49. package/dist/OrderLineEntityServer.d.ts.map +1 -1
  50. package/dist/OrderLineEntityServer.js +15 -4
  51. package/dist/OrderLineEntityServer.js.map +1 -1
  52. package/dist/PaymentAllocationInputs.d.ts +8 -2
  53. package/dist/PaymentAllocationInputs.d.ts.map +1 -1
  54. package/dist/PaymentAllocationInputs.js +26 -5
  55. package/dist/PaymentAllocationInputs.js.map +1 -1
  56. package/dist/PaymentHeaderEntityServer.d.ts +7 -0
  57. package/dist/PaymentHeaderEntityServer.d.ts.map +1 -1
  58. package/dist/PaymentHeaderEntityServer.js +14 -3
  59. package/dist/PaymentHeaderEntityServer.js.map +1 -1
  60. package/dist/PaymentScheduleBehavior.d.ts +74 -21
  61. package/dist/PaymentScheduleBehavior.d.ts.map +1 -1
  62. package/dist/PaymentScheduleBehavior.js +155 -38
  63. package/dist/PaymentScheduleBehavior.js.map +1 -1
  64. package/dist/RecordProgressOperation.d.ts.map +1 -1
  65. package/dist/RecordProgressOperation.js +3 -5
  66. package/dist/RecordProgressOperation.js.map +1 -1
  67. package/dist/ReversalResolver.d.ts.map +1 -1
  68. package/dist/ReversalResolver.js +6 -1
  69. package/dist/ReversalResolver.js.map +1 -1
  70. package/dist/SpawnRenewalsOperation.d.ts +51 -2
  71. package/dist/SpawnRenewalsOperation.d.ts.map +1 -1
  72. package/dist/SpawnRenewalsOperation.js +214 -23
  73. package/dist/SpawnRenewalsOperation.js.map +1 -1
  74. package/dist/SubscriptionBehavior.d.ts +125 -0
  75. package/dist/SubscriptionBehavior.d.ts.map +1 -1
  76. package/dist/SubscriptionBehavior.js +147 -5
  77. package/dist/SubscriptionBehavior.js.map +1 -1
  78. package/dist/SubscriptionFamilyRules.d.ts +34 -0
  79. package/dist/SubscriptionFamilyRules.d.ts.map +1 -0
  80. package/dist/SubscriptionFamilyRules.js +96 -0
  81. package/dist/SubscriptionFamilyRules.js.map +1 -0
  82. package/dist/entity-names.d.ts +2 -0
  83. package/dist/entity-names.d.ts.map +1 -1
  84. package/dist/entity-names.js +2 -0
  85. package/dist/entity-names.js.map +1 -1
  86. package/dist/index.d.ts +4 -2
  87. package/dist/index.d.ts.map +1 -1
  88. package/dist/index.js +3 -1
  89. package/dist/index.js.map +1 -1
  90. 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
- 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),
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: true,
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
- (await this.findExistingSubscription(product.ID, identity)));
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 LoadOrdersEngine(provider, user);
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
- const p = OrdersEngine.Instance.ProductByID(line.ProductID);
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, identity) {
2808
- // Match on exactly the axes the BenefitModel says define a duplicate. An org-members type
2809
- // ignores the person entirely (one company membership, however many employees); a seat type
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
- return this.loadSubscriptionState(`ProductID='${productID}' AND ${clauses.join(' AND ')}`);
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
- /** Load a subscription plus the end and number of its latest term, by whatever filter. */
2821
- 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) {
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
- 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];
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
- /** Extension or reactivation — the term is what changes; the subscription just re-activates. */
2925
- 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) {
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]));