@mj-biz-apps/orders-core-entities-server 0.0.1 → 5.2.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 (267) hide show
  1. package/LICENSE +183 -0
  2. package/README.md +141 -2
  3. package/dist/AccountingBridge.d.ts +65 -0
  4. package/dist/AccountingBridge.d.ts.map +1 -0
  5. package/dist/AccountingBridge.js +102 -0
  6. package/dist/AccountingBridge.js.map +1 -0
  7. package/dist/AdvanceOrderStateOperation.d.ts +80 -0
  8. package/dist/AdvanceOrderStateOperation.d.ts.map +1 -0
  9. package/dist/AdvanceOrderStateOperation.js +267 -0
  10. package/dist/AdvanceOrderStateOperation.js.map +1 -0
  11. package/dist/ApplyAccountCreditOperation.d.ts +97 -0
  12. package/dist/ApplyAccountCreditOperation.d.ts.map +1 -0
  13. package/dist/ApplyAccountCreditOperation.js +275 -0
  14. package/dist/ApplyAccountCreditOperation.js.map +1 -0
  15. package/dist/BaseDeliveryChannel.d.ts +106 -0
  16. package/dist/BaseDeliveryChannel.d.ts.map +1 -0
  17. package/dist/BaseDeliveryChannel.js +36 -0
  18. package/dist/BaseDeliveryChannel.js.map +1 -0
  19. package/dist/BasePaymentProvider.d.ts +239 -0
  20. package/dist/BasePaymentProvider.d.ts.map +1 -0
  21. package/dist/BasePaymentProvider.js +91 -0
  22. package/dist/BasePaymentProvider.js.map +1 -0
  23. package/dist/BundleBehavior.d.ts +139 -0
  24. package/dist/BundleBehavior.d.ts.map +1 -0
  25. package/dist/BundleBehavior.js +173 -0
  26. package/dist/BundleBehavior.js.map +1 -0
  27. package/dist/BundleEngine.d.ts +60 -0
  28. package/dist/BundleEngine.d.ts.map +1 -0
  29. package/dist/BundleEngine.js +197 -0
  30. package/dist/BundleEngine.js.map +1 -0
  31. package/dist/CancelSubscriptionOperation.d.ts +98 -0
  32. package/dist/CancelSubscriptionOperation.d.ts.map +1 -0
  33. package/dist/CancelSubscriptionOperation.js +317 -0
  34. package/dist/CancelSubscriptionOperation.js.map +1 -0
  35. package/dist/CapturePaymentOperation.d.ts +62 -0
  36. package/dist/CapturePaymentOperation.d.ts.map +1 -0
  37. package/dist/CapturePaymentOperation.js +463 -0
  38. package/dist/CapturePaymentOperation.js.map +1 -0
  39. package/dist/CheckEntitlementOperation.d.ts +27 -0
  40. package/dist/CheckEntitlementOperation.d.ts.map +1 -0
  41. package/dist/CheckEntitlementOperation.js +45 -0
  42. package/dist/CheckEntitlementOperation.js.map +1 -0
  43. package/dist/CheckoutSessionService.d.ts +258 -0
  44. package/dist/CheckoutSessionService.d.ts.map +1 -0
  45. package/dist/CheckoutSessionService.js +1557 -0
  46. package/dist/CheckoutSessionService.js.map +1 -0
  47. package/dist/DeliveryBehavior.d.ts +121 -0
  48. package/dist/DeliveryBehavior.d.ts.map +1 -0
  49. package/dist/DeliveryBehavior.js +145 -0
  50. package/dist/DeliveryBehavior.js.map +1 -0
  51. package/dist/DeliveryRecipientResolver.d.ts +38 -0
  52. package/dist/DeliveryRecipientResolver.d.ts.map +1 -0
  53. package/dist/DeliveryRecipientResolver.js +93 -0
  54. package/dist/DeliveryRecipientResolver.js.map +1 -0
  55. package/dist/DeliveryResolver.d.ts +14 -0
  56. package/dist/DeliveryResolver.d.ts.map +1 -0
  57. package/dist/DeliveryResolver.js +45 -0
  58. package/dist/DeliveryResolver.js.map +1 -0
  59. package/dist/EmailDeliveryChannel.d.ts +31 -0
  60. package/dist/EmailDeliveryChannel.d.ts.map +1 -0
  61. package/dist/EmailDeliveryChannel.js +158 -0
  62. package/dist/EmailDeliveryChannel.js.map +1 -0
  63. package/dist/EntitlementBehavior.d.ts +235 -0
  64. package/dist/EntitlementBehavior.d.ts.map +1 -0
  65. package/dist/EntitlementBehavior.js +330 -0
  66. package/dist/EntitlementBehavior.js.map +1 -0
  67. package/dist/EntitlementEngine.d.ts +97 -0
  68. package/dist/EntitlementEngine.d.ts.map +1 -0
  69. package/dist/EntitlementEngine.js +338 -0
  70. package/dist/EntitlementEngine.js.map +1 -0
  71. package/dist/EntitlementGrantClaimDriver.d.ts +42 -0
  72. package/dist/EntitlementGrantClaimDriver.d.ts.map +1 -0
  73. package/dist/EntitlementGrantClaimDriver.js +158 -0
  74. package/dist/EntitlementGrantClaimDriver.js.map +1 -0
  75. package/dist/EntitlementRead.d.ts +82 -0
  76. package/dist/EntitlementRead.d.ts.map +1 -0
  77. package/dist/EntitlementRead.js +368 -0
  78. package/dist/EntitlementRead.js.map +1 -0
  79. package/dist/FulfillOrderLinesOperation.d.ts +34 -0
  80. package/dist/FulfillOrderLinesOperation.d.ts.map +1 -0
  81. package/dist/FulfillOrderLinesOperation.js +208 -0
  82. package/dist/FulfillOrderLinesOperation.js.map +1 -0
  83. package/dist/FulfillmentBehavior.d.ts +101 -0
  84. package/dist/FulfillmentBehavior.d.ts.map +1 -0
  85. package/dist/FulfillmentBehavior.js +145 -0
  86. package/dist/FulfillmentBehavior.js.map +1 -0
  87. package/dist/GLAccountResolver.d.ts +123 -0
  88. package/dist/GLAccountResolver.d.ts.map +1 -0
  89. package/dist/GLAccountResolver.js +175 -0
  90. package/dist/GLAccountResolver.js.map +1 -0
  91. package/dist/GetFulfillmentQueueOperation.d.ts +35 -0
  92. package/dist/GetFulfillmentQueueOperation.d.ts.map +1 -0
  93. package/dist/GetFulfillmentQueueOperation.js +221 -0
  94. package/dist/GetFulfillmentQueueOperation.js.map +1 -0
  95. package/dist/GetOverdueWorklistOperation.d.ts +41 -0
  96. package/dist/GetOverdueWorklistOperation.d.ts.map +1 -0
  97. package/dist/GetOverdueWorklistOperation.js +210 -0
  98. package/dist/GetOverdueWorklistOperation.js.map +1 -0
  99. package/dist/GiftCardBehavior.d.ts +97 -0
  100. package/dist/GiftCardBehavior.d.ts.map +1 -0
  101. package/dist/GiftCardBehavior.js +121 -0
  102. package/dist/GiftCardBehavior.js.map +1 -0
  103. package/dist/GiftCardEngine.d.ts +59 -0
  104. package/dist/GiftCardEngine.d.ts.map +1 -0
  105. package/dist/GiftCardEngine.js +197 -0
  106. package/dist/GiftCardEngine.js.map +1 -0
  107. package/dist/GuestOrderClaimDriver.d.ts +36 -0
  108. package/dist/GuestOrderClaimDriver.d.ts.map +1 -0
  109. package/dist/GuestOrderClaimDriver.js +161 -0
  110. package/dist/GuestOrderClaimDriver.js.map +1 -0
  111. package/dist/InvoiceBehavior.d.ts +394 -0
  112. package/dist/InvoiceBehavior.d.ts.map +1 -0
  113. package/dist/InvoiceBehavior.js +496 -0
  114. package/dist/InvoiceBehavior.js.map +1 -0
  115. package/dist/InvoiceBuilder.d.ts +48 -0
  116. package/dist/InvoiceBuilder.d.ts.map +1 -0
  117. package/dist/InvoiceBuilder.js +352 -0
  118. package/dist/InvoiceBuilder.js.map +1 -0
  119. package/dist/InvoiceDisplay.d.ts +96 -0
  120. package/dist/InvoiceDisplay.d.ts.map +1 -0
  121. package/dist/InvoiceDisplay.js +121 -0
  122. package/dist/InvoiceDisplay.js.map +1 -0
  123. package/dist/ListEntitlementsOperation.d.ts +21 -0
  124. package/dist/ListEntitlementsOperation.d.ts.map +1 -0
  125. package/dist/ListEntitlementsOperation.js +39 -0
  126. package/dist/ListEntitlementsOperation.js.map +1 -0
  127. package/dist/ManualPaymentProvider.d.ts +19 -0
  128. package/dist/ManualPaymentProvider.d.ts.map +1 -0
  129. package/dist/ManualPaymentProvider.js +92 -0
  130. package/dist/ManualPaymentProvider.js.map +1 -0
  131. package/dist/OrderEntityServer.d.ts +542 -0
  132. package/dist/OrderEntityServer.d.ts.map +1 -0
  133. package/dist/OrderEntityServer.js +2100 -0
  134. package/dist/OrderEntityServer.js.map +1 -0
  135. package/dist/OrderJournalEntryFactory.d.ts +140 -0
  136. package/dist/OrderJournalEntryFactory.d.ts.map +1 -0
  137. package/dist/OrderJournalEntryFactory.js +466 -0
  138. package/dist/OrderJournalEntryFactory.js.map +1 -0
  139. package/dist/OrderLineEntityServer.d.ts +90 -0
  140. package/dist/OrderLineEntityServer.d.ts.map +1 -0
  141. package/dist/OrderLineEntityServer.js +260 -0
  142. package/dist/OrderLineEntityServer.js.map +1 -0
  143. package/dist/OrdersSettings.d.ts +36 -0
  144. package/dist/OrdersSettings.d.ts.map +1 -0
  145. package/dist/OrdersSettings.js +147 -0
  146. package/dist/OrdersSettings.js.map +1 -0
  147. package/dist/PaymentAllocationFactory.d.ts +128 -0
  148. package/dist/PaymentAllocationFactory.d.ts.map +1 -0
  149. package/dist/PaymentAllocationFactory.js +235 -0
  150. package/dist/PaymentAllocationFactory.js.map +1 -0
  151. package/dist/PaymentHeaderEntityServer.d.ts +180 -0
  152. package/dist/PaymentHeaderEntityServer.d.ts.map +1 -0
  153. package/dist/PaymentHeaderEntityServer.js +656 -0
  154. package/dist/PaymentHeaderEntityServer.js.map +1 -0
  155. package/dist/PaymentIntentService.d.ts +101 -0
  156. package/dist/PaymentIntentService.d.ts.map +1 -0
  157. package/dist/PaymentIntentService.js +150 -0
  158. package/dist/PaymentIntentService.js.map +1 -0
  159. package/dist/PaymentJournalEntryFactory.d.ts +99 -0
  160. package/dist/PaymentJournalEntryFactory.d.ts.map +1 -0
  161. package/dist/PaymentJournalEntryFactory.js +127 -0
  162. package/dist/PaymentJournalEntryFactory.js.map +1 -0
  163. package/dist/PaymentLineEntityServer.d.ts +73 -0
  164. package/dist/PaymentLineEntityServer.d.ts.map +1 -0
  165. package/dist/PaymentLineEntityServer.js +303 -0
  166. package/dist/PaymentLineEntityServer.js.map +1 -0
  167. package/dist/PaymentProviderBehavior.d.ts +222 -0
  168. package/dist/PaymentProviderBehavior.d.ts.map +1 -0
  169. package/dist/PaymentProviderBehavior.js +368 -0
  170. package/dist/PaymentProviderBehavior.js.map +1 -0
  171. package/dist/PaymentProviderResolver.d.ts +84 -0
  172. package/dist/PaymentProviderResolver.d.ts.map +1 -0
  173. package/dist/PaymentProviderResolver.js +220 -0
  174. package/dist/PaymentProviderResolver.js.map +1 -0
  175. package/dist/PaymentReversalFactory.d.ts +107 -0
  176. package/dist/PaymentReversalFactory.d.ts.map +1 -0
  177. package/dist/PaymentReversalFactory.js +174 -0
  178. package/dist/PaymentReversalFactory.js.map +1 -0
  179. package/dist/PaymentSettlement.d.ts +56 -0
  180. package/dist/PaymentSettlement.d.ts.map +1 -0
  181. package/dist/PaymentSettlement.js +243 -0
  182. package/dist/PaymentSettlement.js.map +1 -0
  183. package/dist/PaymentTermsBehavior.d.ts +109 -0
  184. package/dist/PaymentTermsBehavior.d.ts.map +1 -0
  185. package/dist/PaymentTermsBehavior.js +172 -0
  186. package/dist/PaymentTermsBehavior.js.map +1 -0
  187. package/dist/PaymentWebhookHandler.d.ts +103 -0
  188. package/dist/PaymentWebhookHandler.d.ts.map +1 -0
  189. package/dist/PaymentWebhookHandler.js +246 -0
  190. package/dist/PaymentWebhookHandler.js.map +1 -0
  191. package/dist/PreviewPriceOperation.d.ts +62 -0
  192. package/dist/PreviewPriceOperation.d.ts.map +1 -0
  193. package/dist/PreviewPriceOperation.js +161 -0
  194. package/dist/PreviewPriceOperation.js.map +1 -0
  195. package/dist/PriceOrderOperation.d.ts +93 -0
  196. package/dist/PriceOrderOperation.d.ts.map +1 -0
  197. package/dist/PriceOrderOperation.js +146 -0
  198. package/dist/PriceOrderOperation.js.map +1 -0
  199. package/dist/ProductPriceEntityServer.d.ts +34 -0
  200. package/dist/ProductPriceEntityServer.d.ts.map +1 -0
  201. package/dist/ProductPriceEntityServer.js +97 -0
  202. package/dist/ProductPriceEntityServer.js.map +1 -0
  203. package/dist/RefundPaymentOperation.d.ts +61 -0
  204. package/dist/RefundPaymentOperation.d.ts.map +1 -0
  205. package/dist/RefundPaymentOperation.js +177 -0
  206. package/dist/RefundPaymentOperation.js.map +1 -0
  207. package/dist/RevenueRecognition.d.ts +76 -0
  208. package/dist/RevenueRecognition.d.ts.map +1 -0
  209. package/dist/RevenueRecognition.js +133 -0
  210. package/dist/RevenueRecognition.js.map +1 -0
  211. package/dist/ReversalBehavior.d.ts +82 -0
  212. package/dist/ReversalBehavior.d.ts.map +1 -0
  213. package/dist/ReversalBehavior.js +100 -0
  214. package/dist/ReversalBehavior.js.map +1 -0
  215. package/dist/ReversalResolver.d.ts +37 -0
  216. package/dist/ReversalResolver.d.ts.map +1 -0
  217. package/dist/ReversalResolver.js +96 -0
  218. package/dist/ReversalResolver.js.map +1 -0
  219. package/dist/SpawnRenewalsOperation.d.ts +109 -0
  220. package/dist/SpawnRenewalsOperation.d.ts.map +1 -0
  221. package/dist/SpawnRenewalsOperation.js +295 -0
  222. package/dist/SpawnRenewalsOperation.js.map +1 -0
  223. package/dist/StoredValuePaymentProvider.d.ts +52 -0
  224. package/dist/StoredValuePaymentProvider.d.ts.map +1 -0
  225. package/dist/StoredValuePaymentProvider.js +205 -0
  226. package/dist/StoredValuePaymentProvider.js.map +1 -0
  227. package/dist/StripeACHPaymentProvider.d.ts +50 -0
  228. package/dist/StripeACHPaymentProvider.d.ts.map +1 -0
  229. package/dist/StripeACHPaymentProvider.js +211 -0
  230. package/dist/StripeACHPaymentProvider.js.map +1 -0
  231. package/dist/StripePaymentProvider.d.ts +83 -0
  232. package/dist/StripePaymentProvider.d.ts.map +1 -0
  233. package/dist/StripePaymentProvider.js +442 -0
  234. package/dist/StripePaymentProvider.js.map +1 -0
  235. package/dist/SubscriptionBehavior.d.ts +197 -0
  236. package/dist/SubscriptionBehavior.d.ts.map +1 -0
  237. package/dist/SubscriptionBehavior.js +415 -0
  238. package/dist/SubscriptionBehavior.js.map +1 -0
  239. package/dist/checkoutCaptureAlert.d.ts +16 -0
  240. package/dist/checkoutCaptureAlert.d.ts.map +1 -0
  241. package/dist/checkoutCaptureAlert.js +55 -0
  242. package/dist/checkoutCaptureAlert.js.map +1 -0
  243. package/dist/checkoutCaptureRetry.d.ts +27 -0
  244. package/dist/checkoutCaptureRetry.d.ts.map +1 -0
  245. package/dist/checkoutCaptureRetry.js +50 -0
  246. package/dist/checkoutCaptureRetry.js.map +1 -0
  247. package/dist/claimDriverHelpers.d.ts +15 -0
  248. package/dist/claimDriverHelpers.d.ts.map +1 -0
  249. package/dist/claimDriverHelpers.js +34 -0
  250. package/dist/claimDriverHelpers.js.map +1 -0
  251. package/dist/entity-names.d.ts +15 -0
  252. package/dist/entity-names.d.ts.map +1 -0
  253. package/dist/entity-names.js +15 -0
  254. package/dist/entity-names.js.map +1 -0
  255. package/dist/identityClaimContracts.d.ts +118 -0
  256. package/dist/identityClaimContracts.d.ts.map +1 -0
  257. package/dist/identityClaimContracts.js +58 -0
  258. package/dist/identityClaimContracts.js.map +1 -0
  259. package/dist/index.d.ts +126 -0
  260. package/dist/index.d.ts.map +1 -0
  261. package/dist/index.js +115 -0
  262. package/dist/index.js.map +1 -0
  263. package/dist/sql-guards.d.ts +78 -0
  264. package/dist/sql-guards.d.ts.map +1 -0
  265. package/dist/sql-guards.js +115 -0
  266. package/dist/sql-guards.js.map +1 -0
  267. package/package.json +50 -5
@@ -0,0 +1,2100 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ /**
8
+ * OrderEntityServer — the server-only Order subclass that makes booking atomic (plan D12).
9
+ *
10
+ * On the FIRST transition into `Confirmed` (plan D8), one transaction covers:
11
+ *
12
+ * order header → its lines → one JE per line → each line's JournalEntryID stamp
13
+ *
14
+ * Any failure rolls the whole thing back. A confirmed order without its journal entries is
15
+ * invalid state, so there is deliberately no partial-success path and no compensation logic.
16
+ *
17
+ * The JE set is written by accounting's `Accounting.CreateJournalEntries`, whose own transaction
18
+ * NESTS inside ours as savepoints — so the single outer commit here covers the order row and
19
+ * every entry. (This is why no TransactionGroup plumbing is needed; nesting composes.)
20
+ *
21
+ * PROVIDER DISCIPLINE (plan D12): everything uses `this.ProviderToUse` — the entity's own
22
+ * provider — throughout. A fresh global `Metadata` inside the transaction path would open a
23
+ * SECOND connection that cannot see our uncommitted work, silently breaking atomicity.
24
+ *
25
+ * IDEMPOTENCY: booking keys off `ConfirmedAt`. Once set, re-saving a confirmed order updates the
26
+ * row normally and never re-books. `OrderLine.JournalEntryID` is additionally NULL→value-once at
27
+ * the database level (trigger 51008), so a double-book is refused by the DB even if this class
28
+ * were bypassed.
29
+ *
30
+ * CONNECTS TO:
31
+ * FACTORY: OrderJournalEntryFactory (./OrderJournalEntryFactory.ts)
32
+ * RESOLVER: GLAccountResolver (./GLAccountResolver.ts)
33
+ * OP: 'Accounting.CreateJournalEntries' (@mj-biz-apps/accounting-core-entities-server)
34
+ */
35
+ import { BaseEntity, BaseEntityResult, BaseRemotableOperation, CompositeKey, EntitySaveOptions, LogError, Metadata, RunView, ValidationErrorInfo, ValidationErrorType, } from '@memberjunction/core';
36
+ import { MJGlobal, RegisterClass, UUIDsEqual } from '@memberjunction/global';
37
+ import { OrderHeaderEntity, } from '@mj-biz-apps/orders-entities';
38
+ import { BuildGLAccountResolver, EntityIDFor } from './AccountingBridge.js';
39
+ import { InheritedTerms, ValidateReversal } from './ReversalBehavior.js';
40
+ import { LoadReversalContext } from './ReversalResolver.js';
41
+ import { CreateEntitlementGrants, RevokeGrantsForReturn } from './EntitlementEngine.js';
42
+ import { IssueGiftCards } from './GiftCardEngine.js';
43
+ import { ExpandBundleLines } from './BundleEngine.js';
44
+ import { OrdersSettings } from './OrdersSettings.js';
45
+ import { OrderJournalEntryFactory } from './OrderJournalEntryFactory.js';
46
+ import { RequireUUID } from './sql-guards.js';
47
+ import { ResolveDueDate } from './PaymentTermsBehavior.js';
48
+ import { LineGross, LoadOrdersEngine, NetAfterDiscount, OrderPricingService, OrdersEngine, WriteAdjustments, WriteCharges } from '@mj-biz-apps/orders-entities';
49
+ const CUSTOMER_PAYMENT_TERMS_ENTITY = 'MJ_BizApps_Orders: Customer Payment Terms';
50
+ const ORDER_COMPANY_POLICY_ENTITY = 'MJ_BizApps_Orders: Order Company Policies';
51
+ import { SubscriptionBehavior, } from './SubscriptionBehavior.js';
52
+ const ORDER_ENTITY = 'MJ_BizApps_Orders: Order Headers';
53
+ const CHARGE_TYPE_ENTITY = 'MJ_BizApps_Orders: Charge Types';
54
+ // bizapps-common names its entities with DOTS, not the underscores the other apps use.
55
+ const COMMON_ADDRESS_ENTITY = 'MJ_BizApps_Common: Addresses';
56
+ const ORDER_LINE_ENTITY = 'MJ_BizApps_Orders: Order Lines';
57
+ const PRODUCT_ENTITY = 'MJ_BizApps_Orders: Products';
58
+ const PRODUCT_CATEGORY_ENTITY = 'MJ_BizApps_Orders: Product Categories';
59
+ /** IsA Disjoint child of Product (BO-D37) — present only for products that ARE events. */
60
+ const EVENT_PRODUCT_ENTITY = 'MJ_BizApps_Orders: Event Products';
61
+ const COMPANY_ENTITY = 'MJ: Companies';
62
+ const PAYMENT_HEADER_ENTITY = 'MJ_BizApps_Orders: Payment Headers';
63
+ const PAYMENT_LINE_ENTITY = 'MJ_BizApps_Orders: Payment Lines';
64
+ const PAYMENT_DETAIL_ENTITY = 'MJ_BizApps_Orders: Payment Details';
65
+ /** Carries `RequiresReference`, which decides whether a tender needs a check/wire number. */
66
+ const PAYMENT_TYPE_ENTITY = 'MJ_BizApps_Orders: Payment Types';
67
+ const SUBSCRIPTION_ENTITY = 'MJ_BizApps_Orders: Subscriptions';
68
+ const SUBSCRIPTION_EVENT_ENTITY = 'MJ_BizApps_Orders: Subscription Events';
69
+ const RELATIONSHIP_ENTITY = 'MJ_BizApps_Common: Relationships';
70
+ const COMMON_SCHEMA = '__mj_BizAppsCommon';
71
+ const SUBSCRIPTION_TERM_ENTITY = 'MJ_BizApps_Orders: Subscription Terms';
72
+ const SUBSCRIPTION_TYPE_ENTITY = 'MJ_BizApps_Orders: Subscription Types';
73
+ /** Statuses at or beyond the booking lock (plan D8/D9). */
74
+ const BOOKED_STATUSES = new Set(['Confirmed']);
75
+ /** Shape of the result accounting returns for the JE set. */
76
+ /**
77
+ * Normalize a GUID for use as a Map key.
78
+ *
79
+ * SQL Server returns `UNIQUEIDENTIFIER` uppercased, while an ID that arrived from a caller (or from
80
+ * `randomUUID()`) is lowercase. Both name the same row, and SQL compares them correctly — but a JS
81
+ * `Map` does not, so any lookup that crosses the boundary between "value I was handed" and "value
82
+ * that came back from the database" MUST go through here. Every place this was skipped produced the
83
+ * same failure shape: a silent miss that looks like missing data rather than a key mismatch.
84
+ */
85
+ const uuidKey = (id) => (id ?? '').toLowerCase();
86
+ /**
87
+ * Stands in for a subscription an EARLIER LINE OF THIS ORDER is about to create.
88
+ *
89
+ * The concurrency rules are evaluated against `ExistingSubscription`, which is a
90
+ * database row — so with two lines for the same subscription in one order, the
91
+ * second line saw nothing and decided `CreateNew` too. That silently produced two
92
+ * concurrent subscriptions with identical terms under `ExtendExisting`, and
93
+ * bypassed `RejectDuplicate` entirely. Feeding the sibling's pending decision back
94
+ * in as `Existing` lets the SAME rule code handle the within-order case as the
95
+ * across-order one, rather than duplicating the concurrency logic.
96
+ */
97
+ const PENDING_SIBLING_ID = '__pending-sibling__';
98
+ let OrderEntityServer = class OrderEntityServer extends OrderHeaderEntity {
99
+ constructor() {
100
+ super(...arguments);
101
+ /** Price decompositions produced during this save, written once the lines have IDs (D69). */
102
+ this._priceComponents = new Map();
103
+ /** Why a line owes no tax, by line index — written as a zero-amount component (D73). */
104
+ this._taxReasons = new Map();
105
+ this._manualDiscounts = [];
106
+ this._charges = [];
107
+ /**
108
+ * The total the CALLER was shown, if they want the confirm to refuse when it no longer matches.
109
+ *
110
+ * Between a user reading a total and pressing Confirm, a promotion can expire or a rate can
111
+ * change, and without a guard the order books at the new number in silence. `Orders.ConfirmOrder`
112
+ * accepted `ExpectedGrossTotal` for exactly this and then read it nowhere — a guard that reads as
113
+ * enforced and is not, which is the failure mode this codebase keeps finding.
114
+ *
115
+ * It was implemented once by running an ENTIRE second booking through a rolled-back transaction
116
+ * purely to learn the total, and removed because that cost was not acceptable. Its correct home
117
+ * was always inside the booking transaction, where the real gross exists anyway — which is where
118
+ * it now lives. Left null, nothing changes.
119
+ */
120
+ this.ExpectedGrossTotal = null;
121
+ /**
122
+ * Promotion and charge decisions made by `prepareLines` and consumed by `savePendingLines`.
123
+ *
124
+ * They are decided while the lines are still in memory — a Confirmed line is frozen by trigger
125
+ * 51003 — but the ROWS they produce need line keys, so writing them waits until after the
126
+ * inserts. The two halves used to be one method; they were split when companion validation
127
+ * moved the deadline for a complete line ahead of the header save.
128
+ */
129
+ this._pendingPromotions = null;
130
+ this._pendingCharges = null;
131
+ /** Codes that resolved to nothing usable, so the caller can tell the customer WHY. */
132
+ this._unusableCodes = [];
133
+ }
134
+ // `PromotionCodes` is not declared here any more. It is a COMPANION on the shared subclass, so
135
+ // the browser has it too — which is the entire point: a code typed on screen used to be priced
136
+ // into the preview and then dropped at confirm, because only the server could hold one.
137
+ //
138
+ // Server-side callers that used to assign an array now push through the companion:
139
+ // order.PromotionCodes.Codes = ['SUMMER20'];
140
+ /**
141
+ * Ad-hoc discounts with a stated reason, each gated by the applying user's SalesAuthority.
142
+ *
143
+ * Named `Requested…` for the same reason as {@link RequestedCharges}: `Adjustments` is the
144
+ * collection holding what the engine decided, and a request is not that.
145
+ */
146
+ get RequestedDiscounts() {
147
+ return this._manualDiscounts;
148
+ }
149
+ set RequestedDiscounts(value) {
150
+ this._manualDiscounts = value ?? [];
151
+ }
152
+ /**
153
+ * Charges to apply to this order (D71) — shipping, handling, tax layers. Computed AFTER
154
+ * promotions, because a charge's basis is the discounted line.
155
+ *
156
+ * NOT named `Charges`: that is now the related-record COLLECTION on the generated class, which
157
+ * holds the rows the engine wrote. This is the request channel a server-side caller uses — the
158
+ * two meet in `drainStagedPricingRequests`, which reads staged rows into exactly this shape. A
159
+ * browser has only the collection, because it cannot name a charge type by code.
160
+ */
161
+ get RequestedCharges() {
162
+ return this._charges;
163
+ }
164
+ set RequestedCharges(value) {
165
+ this._charges = value ?? [];
166
+ }
167
+ /**
168
+ * Codes that did not apply, and why — 'no such code', 'not currently running', 'this customer
169
+ * does not qualify'. Silence is the wrong answer here: a customer who typed a code needs to be
170
+ * told it did nothing, and told what to do about it.
171
+ */
172
+ get UnusablePromotionCodes() {
173
+ return this._unusableCodes;
174
+ }
175
+ // `Lines` is not declared here any more. It is a RelatedRecordCollection on the GENERATED class,
176
+ // emitted from the `RelatedRecordCollection` metadata on the 'Order Headers → Order Lines'
177
+ // relationship — so both tiers have it, and the browser can compose an order and ship the whole
178
+ // graph in one `MJ.SaveEntityGraph` call.
179
+ //
180
+ // What it replaced: a `_lines` array plus a getter/setter pair that existed only on the server.
181
+ // Callers assigned a whole array; they now add through the collection (`Lines.Create()` /
182
+ // `Lines.Add()`), which stamps the foreign key and the line number for them, and tracks removals
183
+ // so a dropped line is deleted rather than left orphaned pointing at its order.
184
+ // ─── Validation ────────────────────────────────────────────────────────────
185
+ /**
186
+ * WITHOUT THIS, NOTHING BELOW RUNS.
187
+ *
188
+ * `BaseEntity.DefaultSkipAsyncValidation` returns **true** — `Save()` calls `Validate()` always
189
+ * but reaches `ValidateAsync()` only when a subclass opts in. This class never did, so its
190
+ * entire async validation block was dead code from the day it was written: the "an order
191
+ * entering the booked state must have something to book" rule never fired, and neither did the
192
+ * loop that surfaces each line's `ValidateAsync` failures against the order.
193
+ *
194
+ * That is not a theory. Saving an order straight to Confirmed with ZERO lines succeeded and
195
+ * produced ORD-000030 — a confirmed order with nothing on it. `ProductPriceEntityServer` is the
196
+ * only class in this package that got this right, and its comment says exactly why.
197
+ *
198
+ * The failure mode is the dangerous kind: the rule reads as enforced, reviews as enforced, and
199
+ * is not. If you add a `ValidateAsync` to any entity here, add this override with it.
200
+ */
201
+ get DefaultSkipAsyncValidation() {
202
+ return false;
203
+ }
204
+ /**
205
+ * The one order rule that CANNOT be decided without the database.
206
+ *
207
+ * The payer rule, the status-transition guard and the has-lines rule for a loaded collection all
208
+ * moved to `OrderHeaderEntity.Validate()`, so the browser now refuses those before a round trip.
209
+ * What is left here is the case the browser genuinely cannot answer: an order that is already
210
+ * saved, being confirmed, whose `Lines` collection was never loaded. In memory that is
211
+ * indistinguishable from an empty order — and answering "empty" would refuse a perfectly good
212
+ * confirm of an order whose lines are sitting on disk.
213
+ *
214
+ * Per-line validation is no longer fanned out by hand. `Lines` is a companion, and
215
+ * `BaseEntity.Save()` validates every companion — including pending removals — before the first
216
+ * row is written, attributing failures by position (`Lines[3].Quantity`).
217
+ */
218
+ async ValidateAsync() {
219
+ const result = await super.ValidateAsync();
220
+ // The DEFINITIVE has-something-to-book check: nothing in memory AND nothing on disk.
221
+ //
222
+ // Both halves are required. Asking only about the collection refuses a confirm of a saved
223
+ // order whose lines were never loaded; asking only the database refuses a brand-new order
224
+ // whose lines exist solely in memory — and `Add()` does not mark a collection loaded, so
225
+ // `IsLoaded` cannot stand in for either question.
226
+ if (this.willBookOnThisSave() &&
227
+ this.Lines.Count === 0 &&
228
+ (await this.countPersistedLines()) === 0) {
229
+ result.Success = false;
230
+ result.Errors.push(new ValidationErrorInfo('Status', `Order ${this.OrderNumber ?? ''} cannot be confirmed with no lines — there would be nothing to book.`, this.Status, ValidationErrorType.Failure));
231
+ }
232
+ return result;
233
+ }
234
+ // ─── Save Override ─────────────────────────────────────────────────────────
235
+ async Save(options) {
236
+ // REFUSE AN ILLEGAL STATUS MOVE BEFORE ANYTHING ELSE HAPPENS.
237
+ //
238
+ // The CHECK constraint enforces the legal SET and never enforced the legal MOVES, so
239
+ // `Fulfilled -> Draft` and `Voided -> Confirmed` both saved: a voided order could come back
240
+ // to life, keep the journal entries its reversal had already unwound, and be shipped. Every
241
+ // row valid, the constraint satisfied, nothing looking.
242
+ //
243
+ // Checked HERE rather than in the operations because this is the one path every write goes
244
+ // through — the operations, a workflow, a form, a fixture. A rule enforced anywhere else is a
245
+ // rule that holds until somebody saves an entity directly, which is the failure this codebase
246
+ // has now found three times.
247
+ if (!this.passesStatusTransition())
248
+ return false;
249
+ await this.ApplyPersonPartyDefaults();
250
+ const booking = this.willBookOnThisSave();
251
+ // ORDINARY PATH — no booking, and no line work to do.
252
+ //
253
+ // The test is DIRTINESS, not emptiness. It used to read `this._lines.length === 0`, which
254
+ // worked only because `_lines` was a staging buffer that was emptied after every save: an
255
+ // order that had just been saved had no lines in memory, so the next trivial save took this
256
+ // branch by accident.
257
+ //
258
+ // `Lines` is a live collection now (`ClearAfterSave: false`), so it still holds the order's
259
+ // lines afterwards, and asking about emptiness would send every subsequent edit — changing
260
+ // Notes, say — down the full booking walk: expanding bundles, re-pricing, re-deciding
261
+ // promotions, charges and tax for lines nobody touched.
262
+ //
263
+ // `Dirty` is the question that was always meant: it rolls up the collection, so it is true
264
+ // when a line was added, edited or removed and false when the lines are merely present.
265
+ // That also distinguishes a case emptiness never could — lines loaded but untouched.
266
+ //
267
+ // An UNSAVED header still has to mint `OrderNumber` (NOT NULL). That lives on the
268
+ // full path below, inside the transaction, so a brand-new Draft with no lines must
269
+ // not take this shortcut. Existing drafts with no line edits (Notes, payer, …) can.
270
+ if (!booking && !this.Lines.Dirty && this.IsSaved) {
271
+ return super.Save(options);
272
+ }
273
+ // WHEN IT IS DUE, DECIDED ONCE AND STORED (D83) — AND RESOLVED BEFORE THE TRANSACTION OPENS.
274
+ //
275
+ // `DueDate` is what the aging report ages, what the collections worklist filters on and what
276
+ // the invoice prints, so it is settled at confirm rather than derived per reader; three
277
+ // surfaces deriving it independently is how they end up disagreeing about one date.
278
+ //
279
+ // It runs OUTSIDE the transaction because it is three reads of seeded lookups and nothing
280
+ // more. Inside, those round trips sat in the critical section of every confirm, and the
281
+ // volume bundle — eighty orders back to back — started losing one or two to request
282
+ // timeouts. Booking already holds locks across the ledger; lengthening it to look up terms
283
+ // that cannot change mid-save was avoidable load for no gain.
284
+ //
285
+ // The values land on `this`, so they are persisted by the same `super.Save()` as everything
286
+ // else and remain atomic with the booking.
287
+ if (booking)
288
+ await this.resolveDueDate();
289
+ const dbProvider = this.ProviderToUse;
290
+ // Latch it BEFORE ConfirmedAt is stamped, so the validation that runs inside the
291
+ // `super.Save()` below still knows this is the booking save. Cleared in `finally` — an
292
+ // entity object can be re-saved, and a stale latch would make a later ordinary update
293
+ // re-run the booking-only rules. See `bookingInFlight`.
294
+ this.bookingInFlight = booking;
295
+ try {
296
+ await dbProvider.BeginTransaction();
297
+ // Capture BEFORE any header write. A draft that is being confirmed already has a PK
298
+ // and persisted lines; a brand-new confirm does not. The two paths write lines at
299
+ // different times — see persistPreparedLines below.
300
+ // THE HEADER + EMBEDS, not the lines. `SkipRelatedCollections` is load-bearing.
301
+ //
302
+ // `Lines` is a companion now, so an ordinary `super.Save()` would build a save plan,
303
+ // see more than one node, and persist the lines here as part of the graph. That is the
304
+ // right behaviour for a plain composite and completely wrong for this path: the lines
305
+ // have not been expanded, priced, discounted, charged or taxed yet — all of that runs
306
+ // below, and it has to, because it needs the header's key and the subscription
307
+ // decisions.
308
+ //
309
+ // The failure would not have been obvious either. The lines would insert at their raw
310
+ // quantities against an order that is already Confirmed, and `savePendingLines()` would
311
+ // then try to UPDATE them with the resolved prices — which trigger 51003 refuses,
312
+ // because a booked line is frozen. The error surfaces as an INSERT-EXEC rollback naming
313
+ // neither the line nor the rule.
314
+ //
315
+ // `IsGraphNodeSave` is the wrong flag here: it skips *every* companion, including the
316
+ // InitialPaymentDetail embed. `SkipRelatedCollections` persists embeds and leaves
317
+ // collections for `persistPreparedLines` below.
318
+ //
319
+ // It does NOT suppress companion VALIDATION, and it should not: MJ validates every
320
+ // companion from the parent's save so a cross-record invariant sees the whole graph
321
+ // before the first row lands. That is the right guarantee — but it moves a deadline.
322
+ // The lines are now validated HERE, before any line's own `Save()` runs, so every field
323
+ // a line DERIVES rather than accepts has to exist by this point:
324
+ //
325
+ // CompanyID — stamped from the product by OrderLineEntityServer
326
+ // UnitPrice — resolved by the pricing walk below
327
+ //
328
+ // Both are NOT NULL and neither is ever authored by a caller, so leaving them until
329
+ // after the header save failed every confirm with "Company cannot be null" and then
330
+ // "Unit Price cannot be null" — on columns nobody sets by hand.
331
+ //
332
+ // Hence the whole IN-MEMORY preparation phase runs first: bundles expand into real
333
+ // lines, subscription decisions settle the quantities, and each line is priced. None of
334
+ // it writes a row, and none of it needs the header's key — the collection stamps the
335
+ // foreign key itself.
336
+ // A form that saved the draft and then confirmed reloads the HEADER only.
337
+ // Booking is the only caller that MUST have the collection before it decides subscriptions.
338
+ if (booking && this.IsSaved && !this.Lines.IsLoaded) {
339
+ await this.Lines.Load();
340
+ }
341
+ await this.expandBundles();
342
+ const decisions = booking ? await this.decideSubscriptions() : new Map();
343
+ await this.prepareLines(decisions);
344
+ // CONFIRM-AFTER-DRAFT: the lines already exist. `prepareLines` just prorated them
345
+ // (membership qty 1 → 0.3836). If the header flips to Confirmed first, trigger 51003
346
+ // freezes Quantity/LineTotal* and the UPDATE rolls back inside INSERT-EXEC — the
347
+ // error that names neither the line nor the rule. Write those updates WHILE the
348
+ // header is still Draft. A brand-new confirm INSERTs lines after the header; the
349
+ // trigger is UPDATE/DELETE only, so that path is safe.
350
+ const headerAlreadyPersisted = this.IsSaved;
351
+ if (booking && headerAlreadyPersisted) {
352
+ await this.persistPreparedLines(options, decisions);
353
+ }
354
+ if (booking) {
355
+ this.ConfirmedAt = new Date();
356
+ }
357
+ if (!headerAlreadyPersisted && !this.OrderNumber) {
358
+ this.OrderNumber = await this.assignOrderNumber();
359
+ }
360
+ const savedHeader = await super.Save({ ...options, SkipRelatedCollections: true });
361
+ if (!savedHeader) {
362
+ throw new Error(`Failed to save order header: ${this.LatestResult?.CompleteMessage ?? 'unknown error'}`);
363
+ }
364
+ if (!headerAlreadyPersisted || !booking) {
365
+ await this.persistPreparedLines(options, decisions);
366
+ }
367
+ // THE PRICE THE CALLER WAS SHOWN STILL HOLDS — checked here, inside the transaction,
368
+ // where the real gross already exists.
369
+ //
370
+ // The lines are written by now, so `trg_OrderLine_RollupTotals` has maintained
371
+ // OrderHeader.TotalGross from what actually landed rather than from anything this
372
+ // process computed. Throwing rolls the whole booking back: no journal entries, no
373
+ // subscription, no sequence number consumed.
374
+ //
375
+ // Half a penny is the tolerance for the same reason it is elsewhere in this codebase:
376
+ // the money columns are DECIMAL(18,2), so a penny is the unit of account and anything
377
+ // finer is an artefact of summing in binary floating point.
378
+ if (this.ExpectedGrossTotal != null) {
379
+ const actual = (await this.readBalanceFromRow()).TotalGross ?? 0;
380
+ if (Math.abs(actual - this.ExpectedGrossTotal) >= 0.005) {
381
+ throw new Error(`The order total changed before it was confirmed: you were shown ` +
382
+ `${this.ExpectedGrossTotal.toFixed(2)} and it now comes to ${actual.toFixed(2)}. ` +
383
+ `Nothing has been booked. Review the order and confirm again.`);
384
+ }
385
+ }
386
+ if (booking) {
387
+ const lines = await this.loadLinesForBooking();
388
+ // Subscriptions before booking: a term must exist so recognition entries can anchor
389
+ // to it (D46) and use its anchored/prorated window rather than raw line dates.
390
+ const subs = await this.materializeSubscriptions(lines, decisions, options);
391
+ await this.bookLines(lines, options, subs);
392
+ await this.createInitialPayment(options);
393
+ // ENTITLEMENTS LAST, and INSIDE this transaction (D27/D76).
394
+ //
395
+ // Last because it needs everything above: persisted lines to point at, terms for a
396
+ // subscription-scoped validity window, and — for `OnPaidInFull` timing — the balance
397
+ // that `createInitialPayment` has just moved.
398
+ //
399
+ // Inside, because access and the receivable are the same decision. An order that
400
+ // booked revenue and failed to grant access has charged for nothing; one that granted
401
+ // access without booking has given something away. So a failure here rolls the confirm
402
+ // back rather than being logged and shrugged at.
403
+ await this.grantEntitlements(lines, subs, options);
404
+ // And the mirror: a returned line takes its access with it.
405
+ await this.revokeEntitlementsForReversals(lines, options);
406
+ // GIFT CARDS, alongside entitlements and for the same reason. Selling a gift card
407
+ // that never mints an instrument has taken money for nothing, so a failure here
408
+ // rolls the confirm back rather than being logged. Handles both directions: an
409
+ // ordinary line issues, a reversal line voids what the origin issued.
410
+ await this.issueGiftCards(lines, options);
411
+ }
412
+ await dbProvider.CommitTransaction();
413
+ return true;
414
+ }
415
+ catch (err) {
416
+ LogError(`OrderEntityServer.Save failed for order ${this.OrderNumber ?? this.ID}: ${err}`);
417
+ try {
418
+ await dbProvider.RollbackTransaction();
419
+ }
420
+ catch (rollbackErr) {
421
+ LogError(`Rollback failed after OrderEntityServer.Save error: ${rollbackErr}`);
422
+ }
423
+ // Surface WHY. Without this the caller gets a bare `false` and `LatestResult` still
424
+ // holds the header's SUCCESSFUL save — so a subscription rule rejection or an
425
+ // unresolvable GL account reads as "it just didn't work". The UI, the API and the
426
+ // integration suite all need the reason; the log is not a return value.
427
+ this.RegisterResultHistoryEntry(this.buildFailureResult(err));
428
+ return false;
429
+ }
430
+ finally {
431
+ this.bookingInFlight = false;
432
+ }
433
+ }
434
+ /**
435
+ * Turn a thrown booking error into the `BaseEntityResult` a caller can read off `LatestResult`.
436
+ * The message is the error's own text — GLAccountResolver and SubscriptionBehavior both write
437
+ * messages meant for a human, so passing them through beats a generic "save failed".
438
+ */
439
+ buildFailureResult(err) {
440
+ const result = new BaseEntityResult();
441
+ result.Success = false;
442
+ result.Type = this.IsSaved ? 'update' : 'create';
443
+ result.Message = err instanceof Error ? err.message : String(err);
444
+ // Do not also set `Error` to the same Error — CompleteMessage concatenates
445
+ // Message + Error.message and the user sees the refusal twice.
446
+ result.OriginalValues = this.Fields.map(f => ({ FieldName: f.Name, Value: f.OldValue }));
447
+ result.NewValues = this.Fields.map(f => ({ FieldName: f.Name, Value: f.Value }));
448
+ result.StartedAt = new Date();
449
+ result.EndedAt = new Date();
450
+ return result;
451
+ }
452
+ /**
453
+ * Resolve and store this order's due date, walking the terms rungs (D83).
454
+ *
455
+ * A STATED DUE DATE IS LEFT ALONE. `PaymentTermsBehavior` reports whether the answer came from
456
+ * the caller, and a stated date is never recomputed — that is how a contracts app supplies terms
457
+ * Orders has no way to derive, and how a negotiated date survives the next save.
458
+ *
459
+ * The resolved TERMS are recorded alongside the date when the order did not name them, so the
460
+ * invoice can print "Net 30" rather than inferring it back out of an interval.
461
+ *
462
+ * A failure here does not fail the confirm. Booking is the irreversible step and money has
463
+ * already moved in the ledger; refusing it because a lookup could not be read would be a far
464
+ * worse outcome than an order that falls back to due-on-receipt and says so in the log.
465
+ */
466
+ async resolveDueDate() {
467
+ const provider = this.ProviderToUse;
468
+ const user = this.ContextCurrentUser;
469
+ const orderDate = this.OrderDate ? new Date(this.OrderDate).toISOString().slice(0, 10) : null;
470
+ if (!orderDate)
471
+ return;
472
+ try {
473
+ await LoadOrdersEngine(provider, user);
474
+ const terms = new Map(OrdersEngine.Instance.PaymentTermsTypes.map((t) => [
475
+ String(t.ID).toLowerCase(),
476
+ { PaymentTermsTypeID: String(t.ID), NetDays: t.NetDays ?? null },
477
+ ]));
478
+ const [customerTerms, companyDefault] = await Promise.all([
479
+ this.customerPaymentTerms(provider, user),
480
+ this.companyDefaultTerms(provider, user, terms),
481
+ ]);
482
+ const resolution = ResolveDueDate({
483
+ // `OldValue` is what is on disk. On the confirm save the caller may have set a date
484
+ // moments ago, so the CURRENT value is what "stated" means here.
485
+ StatedDueDate: this.DueDate ? new Date(this.DueDate).toISOString().slice(0, 10) : null,
486
+ StatedPaymentTermsTypeID: this.PaymentTermsTypeID ?? null,
487
+ OrderDate: orderDate,
488
+ CompanyID: this.CompanyID ?? null,
489
+ CustomerTerms: customerTerms,
490
+ CompanyDefault: companyDefault,
491
+ TermsByID: terms,
492
+ });
493
+ if (resolution.WasStated)
494
+ return;
495
+ if (resolution.DueDate)
496
+ this.DueDate = new Date(resolution.DueDate);
497
+ if (resolution.PaymentTermsTypeID) {
498
+ this.PaymentTermsTypeID = resolution.PaymentTermsTypeID;
499
+ }
500
+ }
501
+ catch (err) {
502
+ LogError(`Order ${this.OrderNumber ?? this.ID}: could not resolve payment terms (${err}). ` +
503
+ `The order is due on receipt; configure CustomerPaymentTerms or the selling company's ` +
504
+ `default terms to change that.`);
505
+ if (!this.DueDate)
506
+ this.DueDate = new Date(orderDate);
507
+ }
508
+ }
509
+ /** This buyer's negotiated terms, joined to their net days. */
510
+ async customerPaymentTerms(provider, user) {
511
+ const orgID = this.BillToOrganizationID;
512
+ const personID = this.BillToPersonID;
513
+ if (!orgID && !personID)
514
+ return [];
515
+ const clause = orgID
516
+ ? `OrganizationID = '${RequireUUID(orgID, 'BillToOrganizationID')}'`
517
+ : `PersonID = '${RequireUUID(personID, 'BillToPersonID')}'`;
518
+ const rv = new RunView(provider);
519
+ const result = await rv.RunView({
520
+ EntityName: CUSTOMER_PAYMENT_TERMS_ENTITY,
521
+ ExtraFilter: `${clause} AND Status = 'Active'`,
522
+ // ORDER IS PART OF THE CONTRACT, not a convenience. `BestCustomerTerms` picks the
523
+ // most recently started row, and without an ORDER BY the rows arrive in whatever
524
+ // order the engine chooses — so a bug that simply kept the FIRST row would give the
525
+ // right answer or the wrong one depending on the plan, and a test could pass twice
526
+ // and fail on the third run. Oldest first makes "keep the first" always wrong, which
527
+ // is what makes the guard against it meaningful.
528
+ OrderBy: 'StartedAt',
529
+ ResultType: 'simple',
530
+ }, user);
531
+ if (!result?.Success)
532
+ return [];
533
+ const byID = new Map(OrdersEngine.Instance.PaymentTermsTypes.map((t) => [String(t.ID).toLowerCase(), t]));
534
+ return (result.Results ?? []).map((row) => ({
535
+ PaymentTermsTypeID: row.PaymentTermsTypeID,
536
+ NetDays: byID.get(String(row.PaymentTermsTypeID).toLowerCase())?.NetDays ?? null,
537
+ CompanyID: row.CompanyID ?? null,
538
+ StartedAt: row.StartedAt ? new Date(row.StartedAt).toISOString() : null,
539
+ EndedAt: row.EndedAt ? new Date(row.EndedAt).toISOString() : null,
540
+ Status: row.Status,
541
+ }));
542
+ }
543
+ /**
544
+ * The selling company's default terms — the last step of the due-date walk before "due on
545
+ * receipt" (D83).
546
+ *
547
+ * Reads `OrderCompanyPolicy`, NOT accounting's `AccountingCompanyProfile`.
548
+ *
549
+ * It used to read the latter, and accounting removed that column (their issue #22) on grounds
550
+ * this codebase agrees with: accounting records what was owed and when, but deciding WHEN AN
551
+ * ORDER IS DUE is a selling decision, so the default belongs to the app that makes it. Orders
552
+ * kept reading the removed column, so every order whose customer had no negotiated terms failed
553
+ * the walk with `Invalid column name 'DefaultPaymentTermsTypeID'` — six integration checks, and
554
+ * in production the entire company-default step.
555
+ *
556
+ * `OrderCompanyPolicy` is the right home and needed no new table: it is already the per-company
557
+ * orders policy row, IS-A `__mj.Company` so its ID *is* the company ID, and a company with no
558
+ * row simply takes the defaults — which here means falling through to due on receipt.
559
+ */
560
+ async companyDefaultTerms(provider, user, terms) {
561
+ if (!this.CompanyID)
562
+ return null;
563
+ const rv = new RunView(provider);
564
+ const result = await rv.RunView({
565
+ EntityName: ORDER_COMPANY_POLICY_ENTITY,
566
+ ExtraFilter: `ID = '${RequireUUID(this.CompanyID, 'CompanyID')}'`,
567
+ ResultType: 'simple',
568
+ }, user);
569
+ const id = result?.Results?.[0]?.DefaultPaymentTermsTypeID;
570
+ return id ? (terms.get(String(id).toLowerCase()) ?? null) : null;
571
+ }
572
+ /**
573
+ * Refuse an illegal status move BEFORE this save does anything, and say why.
574
+ *
575
+ * ONLY the lifecycle verdict is asked here, deliberately. `super.Save()` runs the full
576
+ * `Validate()` later, and that is the right place for the rest of it: `Validate()` includes the
577
+ * generated NOT NULL field checks, and `OrderNumber`, `Company` and every line's `UnitPrice` are
578
+ * populated BY this save — minted from the sequence, or resolved by the pricing walk below. An
579
+ * attempt to run all of `Validate()` up front refused every confirm in the suite with
580
+ * "Order Number cannot be null", on fields the save was about to fill in.
581
+ *
582
+ * The transition check has no such dependency: it reads the persisted `Status` against the new
583
+ * one, and both are known before anything runs. Asking it here is what makes integration check
584
+ * OS4 hold — a refused move must change nothing on disk and book nothing, and by the time
585
+ * control reaches `super.Save()` this method has already priced lines, decided promotions and
586
+ * charges, minted an order number and posted journal entries.
587
+ *
588
+ * The booking rules that DO need the full record — a payer, something to book — live on
589
+ * `OrderHeaderEntity.Validate()` and fire inside `super.Save()`, where `bookingInFlight` keeps
590
+ * `willBookOnThisSave()` answering true even though `ConfirmedAt` has just been stamped.
591
+ *
592
+ * Reports through `LatestResult`, the same shape every other refusal on this path uses, so a
593
+ * caller gets the reason rather than a bare `false`.
594
+ */
595
+ passesStatusTransition() {
596
+ const verdict = this.statusTransitionVerdict();
597
+ if (verdict.Allowed)
598
+ return true;
599
+ this.RegisterResultHistoryEntry(this.buildFailureResult(new Error(this.statusTransitionRefusal(verdict))));
600
+ return false;
601
+ }
602
+ // ─── Booking ───────────────────────────────────────────────────────────────
603
+ // `bookingInFlight` and `willBookOnThisSave()` moved to OrderHeaderEntity (both `protected`),
604
+ // because the rules that consult them — must have a payer, must have something to book — are
605
+ // decidable without the database and now run on both tiers.
606
+ /**
607
+ * Settle every line's money IN MEMORY. Writes nothing.
608
+ *
609
+ * EVERY in-memory decision happens before any row goes down. That ordering is forced rather
610
+ * than tidy: a Confirmed line is frozen by trigger 51003, and because the CRUD procs run under
611
+ * INSERT-EXEC, a trigger rollback raises 'Cannot use the ROLLBACK statement within an
612
+ * INSERT-EXEC statement' — an error naming neither the line nor the rule it broke. So anything
613
+ * that changes a line's money must be settled BEFORE the insert, not corrected after it.
614
+ *
615
+ * Split out of `savePendingLines` and hoisted ahead of the header save when `Lines` became a
616
+ * related-record collection: MJ validates companions from the parent's save, so a line has to be
617
+ * complete — `CompanyID` stamped, `UnitPrice` resolved, both NOT NULL and neither ever authored
618
+ * by a caller — before that save runs, not after it.
619
+ */
620
+ async prepareLines(decisions) {
621
+ // Lines whose money came from the line they reverse (D16) rather than from the price table.
622
+ // The pricing service is told to leave these alone.
623
+ const settledFromOrigin = new Set();
624
+ for (const line of this.Lines.Items) {
625
+ // Scale the QUANTITY, not DiscountPct: a short first period is not a concession, and
626
+ // routing it through the discount field would corrupt discount reporting and post the
627
+ // difference to the Sales Discounts contra account, where it does not belong.
628
+ // Rounded to 4dp — `OrderLine.Quantity`'s scale — so the stored value and any later
629
+ // recomputation of the line total agree exactly.
630
+ const decided = decisions?.get(line);
631
+ const term = decided?.Decision.Term;
632
+ if (term?.IsProrated && term.ProrationFactor != null) {
633
+ line.Quantity = Math.round(line.Quantity * term.ProrationFactor * 1e4) / 1e4;
634
+ }
635
+ if (term) {
636
+ if (term.StartDate)
637
+ line.ServicePeriodStart = term.StartDate;
638
+ if (term.EndDate)
639
+ line.ServicePeriodEnd = term.EndDate;
640
+ }
641
+ // Events carry their own service period — the event happens when it happens.
642
+ await this.applyEventServicePeriod(line);
643
+ // A REVERSAL is settled from its origin, not from the price table (D16). This must come
644
+ // before pricing: `ComputeAmount` refuses a negative quantity outright — "which volume
645
+ // band does -5 land in?" has no answer — so a reversal that reached the pricing engine
646
+ // would fail there, with a message about quantity rather than about the return.
647
+ const inheritedFromOrigin = await this.applyReversalOrigin(line);
648
+ // Price after proration has settled the quantity — quantity bands and tiers are a
649
+ // function of the quantity actually being bought, so pricing a pre-prorated quantity
650
+ // would band on a number that never reaches the line.
651
+ //
652
+ // NOT for negative quantities. A negative line with no origin is refused by the line's
653
+ // own validation, which names `ReversesOrderLineID` and tells the reader what would make
654
+ // it legal — but pricing runs first and `ComputeAmount` throws about volume bands
655
+ // instead, replacing a message that helps with one that does not.
656
+ if (inheritedFromOrigin)
657
+ settledFromOrigin.add(line);
658
+ // The line's OWN derived fields — CompanyID from the product, and the computed totals.
659
+ // Normally done inside `OrderLineEntityServer.Save()`; hoisted here for the same reason
660
+ // the rest of this method is, so the line is complete before companion validation sees
661
+ // it. Idempotent, so its own `Save()` re-deriving them below changes nothing.
662
+ const serverLine = line;
663
+ if (typeof serverLine.PrepareForSave === 'function') {
664
+ await serverLine.PrepareForSave();
665
+ }
666
+ else if (line.ISAParent) {
667
+ const parentServerLine = line.ISAParent;
668
+ if (typeof parentServerLine.PrepareForSave === 'function') {
669
+ await parentServerLine.PrepareForSave();
670
+ }
671
+ }
672
+ }
673
+ // A BROWSER ASKS FOR CHARGES AND DISCOUNTS THROUGH THE COLLECTIONS, so drain them into the
674
+ // request arrays before pricing runs. See `drainStagedPricingRequests` for why the rows are
675
+ // consumed rather than saved as they arrive.
676
+ await this.drainStagedPricingRequests();
677
+ // PRICING, PROMOTIONS, CHARGES AND TAX — one call to the service that also answers
678
+ // `Orders.PriceOrder`, so the number the screen shows and the number the ledger books come
679
+ // from the same code rather than from two implementations that agree until they do not.
680
+ const pricing = await new OrderPricingService({
681
+ Provider: this.ProviderToUse,
682
+ User: this.ContextCurrentUser,
683
+ }).Price({
684
+ OrderHeaderID: this.ID ?? null,
685
+ CompanyID: this.CompanyID,
686
+ BillToPersonID: this.BillToPersonID ?? null,
687
+ BillToOrganizationID: this.BillToOrganizationID ?? null,
688
+ OrderDate: this.OrderDate ?? null,
689
+ ShipToAddressID: this.ShipToAddressID ?? null,
690
+ Lines: [...this.Lines.Items],
691
+ PromotionCodes: this.PromotionCodes.Codes,
692
+ ManualDiscounts: this._manualDiscounts,
693
+ Charges: this._charges,
694
+ }, settledFromOrigin);
695
+ this._unusableCodes = pricing.UnusableCodes;
696
+ this._taxReasons = pricing.TaxReasons;
697
+ this._priceComponents = pricing.PriceComponents;
698
+ this._pendingPromotions = pricing.Promotions;
699
+ this._pendingCharges = pricing.Charges;
700
+ }
701
+ /**
702
+ * Write the lines that `prepareLines` settled, plus the adjustment rows that need their keys.
703
+ */
704
+ /**
705
+ * Write the adjustment and allocation rows once the lines have real IDs.
706
+ *
707
+ * These only ADD rows — the frozen line is never touched again, which is what keeps this clear
708
+ * of the immutability trigger.
709
+ */
710
+ async writePromotionRecords(run, persisted) {
711
+ if (!run.Applications.length)
712
+ return;
713
+ const provider = this.ProviderToUse;
714
+ const user = this.ContextCurrentUser;
715
+ // Positional keys become real IDs.
716
+ const applications = run.Applications.map((a) => ({
717
+ ...a,
718
+ OrderLineID: a.OrderLineID != null ? (persisted[Number(a.OrderLineID)]?.ID ?? null) : null,
719
+ }));
720
+ await WriteAdjustments(this.ID, applications, new Map(), [], user?.ID ?? null, provider, user);
721
+ }
722
+ /** Net for a line that has not been saved yet — mirrors OrderLineEntityServer's own formula. */
723
+ pendingLineNet(line) {
724
+ // Via LineGross for the same reason the other two sites do: a subscription sold
725
+ // on a Flat rule must price its TERM at the flat amount, not at a re-multiplied
726
+ // unit rate, or the term, the line and the journal entry disagree by pennies and
727
+ // deferred revenue never clears to zero.
728
+ return NetAfterDiscount(LineGross(Number(line.Quantity ?? 0), Number(line.UnitPrice ?? 0), this.resolvedExtendedFor(line)), Math.round(Number(line.DiscountPct ?? 0) * 1e4) / 1e4, Number(line.DiscountAmount ?? 0));
729
+ }
730
+ /**
731
+ * The exact extended amount a price rule computed for this line, if one did.
732
+ *
733
+ * `_priceComponents` is populated by the pricing pass in this same save, so this
734
+ * is the authority while the line is still in flight; the line itself carries the
735
+ * same figure for its own totals hook, which runs outside this class.
736
+ */
737
+ resolvedExtendedFor(line) {
738
+ return this._priceComponents.get(line)?.ExtendedAmount ?? null;
739
+ }
740
+ /** Persist priced lines and the component/charge rows that need their IDs. */
741
+ async persistPreparedLines(options, decisions) {
742
+ await this.savePendingLines(options, decisions);
743
+ await this.savePriceComponents(options);
744
+ }
745
+ async savePendingLines(options, _decisions) {
746
+ const pending = this._pendingPromotions;
747
+ const charges = this._pendingCharges;
748
+ const persisted = [];
749
+ for (const line of this.Lines.Items) {
750
+ const serverLine = line;
751
+ serverLine.BypassBookedCheck = true;
752
+ const saved = await line.Save(options);
753
+ if (!saved) {
754
+ throw new Error(`Failed to save order line ${line.LineNumber}: ${ExtractEntityErrorMessage(line)}`);
755
+ }
756
+ persisted.push(line);
757
+ }
758
+ await this.saveTaxReasons(persisted);
759
+ // The lines are NOT emptied here any more. `this._lines = []` drained a staging buffer that
760
+ // existed because the old wire format shipped a draft and discarded it. The collection is
761
+ // declared `ClearAfterSave: false`, so it stays a live view of what was just persisted —
762
+ // carrying the server-assigned keys — which is what a UI bound straight to `order.Lines`
763
+ // needs, and what the caller gets back from a graph save.
764
+ // The adjustment and charge rows need line IDs, so they follow the insert — but they only ADD
765
+ // rows and never touch the frozen line again.
766
+ if (pending)
767
+ await this.writePromotionRecords(pending, persisted);
768
+ if (charges)
769
+ await this.writeChargeRecords(charges, persisted);
770
+ }
771
+ /** Write the charge and allocation rows once the lines have real IDs. */
772
+ async writeChargeRecords(result, persisted) {
773
+ if (!result.Charges.length)
774
+ return;
775
+ const provider = this.ProviderToUse;
776
+ const user = this.ContextCurrentUser;
777
+ await WriteCharges(this.ID, result, (positional) => persisted[Number(positional)]?.ID ?? null, user?.ID ?? null, provider, user);
778
+ }
779
+ /**
780
+ * Create the grants this order's lines confer (D27/D76).
781
+ *
782
+ * Delegates entirely to `EntitlementEngine`; what lives here is the mapping from the order's own
783
+ * entities to the structural shape the engine takes, plus the balance the timing rule needs.
784
+ *
785
+ * `Balance` is re-read from the header rather than trusted from memory: `createInitialPayment`
786
+ * has just run, and the rollup triggers (D41) moved `AmountPaid`/`Balance` on the ROW without
787
+ * telling this object. An `OnPaidInFull` grant reading a stale balance would sit Suspended on an
788
+ * order that is already paid.
789
+ */
790
+ async grantEntitlements(lines, subs, options) {
791
+ const provider = this.ProviderToUse;
792
+ const user = this.ContextCurrentUser;
793
+ const fresh = await this.readBalanceFromRow();
794
+ await CreateEntitlementGrants({
795
+ ID: this.ID,
796
+ OrderDate: this.OrderDate ? new Date(this.OrderDate) : new Date(),
797
+ Balance: fresh.Balance,
798
+ TotalGross: fresh.TotalGross,
799
+ BillToPersonID: this.BillToPersonID ?? null,
800
+ BillToOrganizationID: this.BillToOrganizationID ?? null,
801
+ }, lines.map((l) => ({
802
+ ID: l.ID,
803
+ ProductID: l.ProductID,
804
+ Quantity: Number(l.Quantity ?? 0),
805
+ ShipToPersonID: l.ShipToPersonID ?? null,
806
+ ShipToOrganizationID: l.ShipToOrganizationID ?? null,
807
+ })), subs.TermsByLine, provider, user, options);
808
+ }
809
+ /**
810
+ * Turn any bundle line into a parent plus its component children (D32/D41/D45).
811
+ *
812
+ * Delegates to `BundleEngine`; what lives here is making a fresh line entity the way this class
813
+ * does, and giving the parent an ID before either row is written so the children can point at it
814
+ * at INSERT time rather than through a later update the immutability trigger would refuse.
815
+ */
816
+ /**
817
+ * Turn charge and adjustment rows a CLIENT staged into the engine's request arrays.
818
+ *
819
+ * ## Why this exists
820
+ *
821
+ * `Charges` and `Adjustments` are related-record collections so a browser can ask for a shipping
822
+ * charge or an ad-hoc discount and have it cross the wire with the order. Before them these were
823
+ * transient arrays only the server could fill, so once `OrderDraft` was deleted a promotion code
824
+ * or a manual discount typed on screen showed up in the PRICE PREVIEW and then vanished at
825
+ * confirm — the screen and the ledger disagreeing, silently, which is the one failure this app
826
+ * spends most of its guard rails preventing.
827
+ *
828
+ * ## Why the staged rows are CONSUMED rather than saved as they arrive
829
+ *
830
+ * A staged row is a REQUEST, and it is not the same object as the record the engine writes. A
831
+ * client can fill in three of a charge's fifteen fields — the type, an amount or rate, and a
832
+ * reason for an override. `BasisAmount`, the per-line allocations, the tax jurisdiction and the
833
+ * sequence are all decided by the pricing walk, and the allocations cannot even be written until
834
+ * the lines have IDs. Letting the graph write the row as it arrived would put a half-formed
835
+ * charge in the transaction and then need a second write to correct it — with a window in
836
+ * between where the order carries a charge with no basis.
837
+ *
838
+ * So the rows are read as requests and removed, and `WriteCharges` / the promotion writer produce
839
+ * the authoritative rows exactly as they always have. The engine is untouched by this change,
840
+ * which matters: it is the part with 373 integration checks against it.
841
+ *
842
+ * On the way OUT the collections mean what they say — `Charges.Load()` on a saved order returns
843
+ * what the engine decided. Request on the way in, record on the way out.
844
+ */
845
+ async drainStagedPricingRequests() {
846
+ const staged = this.Charges.Items.filter((c) => !c.IsSaved);
847
+ if (staged.length) {
848
+ const codes = await this.chargeTypeCodesByID(staged.map((c) => c.ChargeTypeID));
849
+ const requests = [];
850
+ for (const row of staged) {
851
+ const code = codes.get(String(row.ChargeTypeID ?? '').toLowerCase());
852
+ if (!code) {
853
+ // Refused rather than dropped. A charge type that does not resolve is a request
854
+ // the customer will not be billed for, and silently ignoring it is how an order
855
+ // ships without its freight.
856
+ throw new Error(`Charge type '${row.ChargeTypeID}' does not exist, so the charge cannot be applied.`);
857
+ }
858
+ requests.push({
859
+ Code: code,
860
+ Amount: row.Amount ?? null,
861
+ Rate: row.Rate ?? null,
862
+ OverrideReason: row.OverrideReason ?? null,
863
+ ...(row.IsOverridden ? { OverrideAmount: row.Amount ?? null } : {}),
864
+ });
865
+ this.Charges.Remove(row);
866
+ }
867
+ this._charges = [...this._charges, ...requests];
868
+ }
869
+ const stagedAdjustments = this.Adjustments.Items.filter((a) => !a.IsSaved);
870
+ for (const row of stagedAdjustments) {
871
+ // Only MANUAL adjustments can be staged. One naming a promotion is the engine's own
872
+ // output being handed back to it, which would double the discount.
873
+ if (row.PromotionID || row.PromotionCodeID) {
874
+ throw new Error('A promotion adjustment cannot be supplied on an order — present the code and let ' +
875
+ 'the engine decide whether it applies, to whom, and for how much.');
876
+ }
877
+ this._manualDiscounts = [
878
+ ...this._manualDiscounts,
879
+ {
880
+ OrderLineID: row.OrderLineID ?? null,
881
+ Amount: row.Amount ?? null,
882
+ Reason: row.Reason ?? '',
883
+ },
884
+ ];
885
+ this.Adjustments.Remove(row);
886
+ }
887
+ }
888
+ /** `ChargeType.ID` → `Code`, for the staged rows only. One read, not one per row. */
889
+ async chargeTypeCodesByID(ids) {
890
+ const out = new Map();
891
+ const unique = [...new Set(ids.map((i) => String(i ?? '').trim()).filter(Boolean))];
892
+ if (!unique.length)
893
+ return out;
894
+ const rv = new RunView(this.ProviderToUse);
895
+ const res = await rv.RunView({
896
+ EntityName: CHARGE_TYPE_ENTITY,
897
+ ExtraFilter: `ID IN (${unique.map((id) => `'${id.replace(/'/g, "''")}'`).join(',')})`,
898
+ ResultType: 'simple',
899
+ }, this.ContextCurrentUser);
900
+ for (const row of res.Results ?? [])
901
+ out.set(String(row.ID).toLowerCase(), row.Code);
902
+ return out;
903
+ }
904
+ async expandBundles() {
905
+ if (!this.Lines.Count)
906
+ return;
907
+ const provider = this.ProviderToUse;
908
+ const user = this.ContextCurrentUser;
909
+ // A parent needs an ID the children can name. Unsaved lines may not have one yet, and the
910
+ // database default would only assign it at insert — too late for the child rows going down
911
+ // in the same batch.
912
+ for (const line of this.Lines.Items) {
913
+ // `Set`, not `line.ID = …`: the primary key is ReadOnly on the generated class and has
914
+ // no setter. BaseEntity allows exactly one write to a ReadOnly field on a new record,
915
+ // which is what mints the id here.
916
+ if (!line.ID)
917
+ line.Set('ID', crypto.randomUUID().toUpperCase());
918
+ }
919
+ // ExpandBundleLines APPENDS to the array it is handed, and `Lines.Items` is readonly by
920
+ // design — push/splice would bypass the FK stamping, sequence maintenance and removal
921
+ // tracking the collection exists to guarantee. So it expands a working copy and the children
922
+ // it created are attached through `Add()`, which stamps OrderHeaderID for us.
923
+ const working = [...this.Lines.Items];
924
+ const before = working.length;
925
+ await ExpandBundleLines(working, async () => {
926
+ const row = await provider.GetEntityObject('MJ_BizApps_Orders: Order Lines', user);
927
+ row.NewRecord();
928
+ row.Set('ID', crypto.randomUUID().toUpperCase()); // ReadOnly PK — see above
929
+ return row;
930
+ }, provider, user);
931
+ if (working.length === before)
932
+ return;
933
+ for (const child of working.slice(before)) {
934
+ this.Lines.Add(child);
935
+ }
936
+ // REORDER AND RENUMBER. Children are appended to the end of the collection, so without this
937
+ // a two-bundle order interleaves as parent, parent, child, child, child, child — unreadable
938
+ // on an invoice and impossible to group by eye. Putting each child directly beneath its
939
+ // parent is also what `UQ_OrderLine_OrderHeader_LineNumber` needs: the children arrive with
940
+ // no LineNumber at all, and the column is NOT NULL.
941
+ const byParent = new Map();
942
+ const roots = [];
943
+ for (const line of this.Lines.Items) {
944
+ const parentID = line.ParentOrderLineID;
945
+ if (parentID) {
946
+ const k = parentID.toLowerCase();
947
+ if (!byParent.has(k))
948
+ byParent.set(k, []);
949
+ byParent.get(k).push(line);
950
+ }
951
+ else {
952
+ roots.push(line);
953
+ }
954
+ }
955
+ const ordered = [];
956
+ for (const root of roots) {
957
+ ordered.push(root);
958
+ for (const child of byParent.get((root.ID ?? '').toLowerCase()) ?? [])
959
+ ordered.push(child);
960
+ }
961
+ // Anything whose parent is not on this order still has to be saved rather than dropped.
962
+ for (const line of this.Lines.Items)
963
+ if (!ordered.includes(line))
964
+ ordered.push(line);
965
+ // ASSIGNED LAST, and deliberately after every `Add()` above.
966
+ //
967
+ // The collection declares `Sequence: { Field: 'LineNumber', From: 1 }`, and MJ's
968
+ // `applySequence()` re-stamps LineNumber by ARRAY INDEX on every `Add()` and `Create()`. So
969
+ // numbering the lines before attaching the bundle children would have been silently undone
970
+ // by the next `Add()`. It does not run at save time, which makes this explicit pass the last
971
+ // writer.
972
+ //
973
+ // The collection's in-memory order stays "originals, then children", which no longer
974
+ // matters: `OrderBy: 'LineNumber ASC'` is what it reloads by, so the parent/child grouping
975
+ // is what every reader sees.
976
+ ordered.forEach((line, i) => {
977
+ line.LineNumber = i + 1;
978
+ });
979
+ }
980
+ /**
981
+ * Mint the stored-value instruments this order's gift-card lines sell (D44).
982
+ *
983
+ * Delegates to `GiftCardEngine`; what lives here is the mapping from the order's entities to the
984
+ * shape the engine takes. The engine is idempotent against the database, so a re-save of an
985
+ * already confirmed order issues nothing — which matters because a second set of cards would be
986
+ * free money that reconciles perfectly, with the accounts present and the ledger balanced.
987
+ *
988
+ * `IssuingCompanyID` is the ORDER's company rather than any line's. A gift card is a claim on
989
+ * the entity that sold it, and on a multi-company order the seller is the header's company even
990
+ * where a line's product belongs to a sibling.
991
+ */
992
+ async issueGiftCards(lines, options) {
993
+ const provider = this.ProviderToUse;
994
+ const user = this.ContextCurrentUser;
995
+ await IssueGiftCards({
996
+ ID: this.ID,
997
+ IssuingCompanyID: this.CompanyID,
998
+ BillToPersonID: this.BillToPersonID ?? null,
999
+ BillToOrganizationID: this.BillToOrganizationID ?? null,
1000
+ }, lines.map((l) => ({
1001
+ ID: l.ID,
1002
+ ProductID: l.ProductID,
1003
+ Quantity: Number(l.Quantity ?? 0),
1004
+ UnitPrice: Number(l.UnitPrice ?? 0),
1005
+ ReversesOrderLineID: l.ReversesOrderLineID ?? null,
1006
+ ShipToPersonID: l.ShipToPersonID ?? null,
1007
+ ShipToOrganizationID: l.ShipToOrganizationID ?? null,
1008
+ })), provider, user, options);
1009
+ }
1010
+ /** The header's rollup columns as the DATABASE now holds them, after the payment triggers ran. */
1011
+ async readBalanceFromRow() {
1012
+ const rv = new RunView(this.ProviderToUse);
1013
+ const result = await rv.RunView({
1014
+ EntityName: ORDER_ENTITY,
1015
+ ExtraFilter: `ID = '${this.ID}'`,
1016
+ Fields: ['Balance', 'TotalGross'],
1017
+ ResultType: 'simple',
1018
+ }, this.ContextCurrentUser);
1019
+ const row = result?.Results?.[0];
1020
+ return {
1021
+ Balance: row?.Balance ?? this.Balance ?? null,
1022
+ TotalGross: row?.TotalGross ?? this.TotalGross ?? null,
1023
+ };
1024
+ }
1025
+ /**
1026
+ * Take access away from what was sent back.
1027
+ *
1028
+ * A full return revokes; a partial return reduces the quantity proportionally. Uncountable grants
1029
+ * — a Feature, an AccessLevel — survive a partial return, because the customer still holds some of
1030
+ * the thing that conferred them.
1031
+ *
1032
+ * Runs on the REVERSAL order's lines, unwinding the grants that hang off each ORIGIN line. The
1033
+ * origin's quantity is what the proportion is taken against, so it is read from the origin rather
1034
+ * than inferred from the reversal.
1035
+ */
1036
+ async revokeEntitlementsForReversals(lines, options) {
1037
+ const reversals = lines.filter((l) => l.ReversesOrderLineID);
1038
+ if (!reversals.length)
1039
+ return;
1040
+ const provider = this.ProviderToUse;
1041
+ const user = this.ContextCurrentUser;
1042
+ for (const line of reversals) {
1043
+ const reverses = line.ReversesOrderLineID;
1044
+ const context = await LoadReversalContext(reverses, provider, user, [line.ID]);
1045
+ if (!context)
1046
+ continue; // applyReversalOrigin already refused anything unresolvable
1047
+ await RevokeGrantsForReturn(reverses, context.Origin.Quantity, Number(line.Quantity ?? 0), `Returned on order ${this.OrderNumber ?? this.ID}`, provider, user, options);
1048
+ }
1049
+ }
1050
+ /**
1051
+ * Settle a reversal line from the line it unwinds (D16). Returns true when this line WAS a
1052
+ * reversal, so the caller knows to skip ordinary pricing.
1053
+ *
1054
+ * THREE THINGS ONLY THE ORIGIN KNOWS, and each of them produces a balanced journal entry when
1055
+ * it goes wrong — which is why they are refusals here rather than checks somewhere downstream:
1056
+ *
1057
+ * - **How much is left.** Returning 5 against a line that sold 2 refunds money never
1058
+ * collected. The entry balances; nothing later in the pipeline can tell.
1059
+ * - **What it cost.** Pricing a return against today's table refunds last year's purchase at
1060
+ * this year's rate. A stated `UnitPrice` still wins — see `applyResolvedPrice` — because a
1061
+ * stated price is a decision somebody made, and a negotiated return settlement is exactly
1062
+ * that.
1063
+ * - **Which product.** An ID copied from the wrong row credits another company's revenue.
1064
+ *
1065
+ * Runs on any line carrying `ReversesOrderLineID`, including a POSITIVE one: a line that points
1066
+ * at an origin is making a claim about that origin, and the claim is checkable either way.
1067
+ */
1068
+ async applyReversalOrigin(line) {
1069
+ const reverses = line.ReversesOrderLineID;
1070
+ if (!reverses) {
1071
+ // A negative line with no origin. `OrderLineEntityServer.ValidateAsync` says this too,
1072
+ // and says it well — but it never gets the chance: pricing is skipped for a negative
1073
+ // quantity, so `UnitPrice` stays null and the NOT NULL field check fires first with
1074
+ // "Unit Price cannot be null", which sends the reader to entirely the wrong field.
1075
+ // Refusing here keeps the message that names what would make the line legal.
1076
+ if (Number(line.Quantity ?? 0) < 0) {
1077
+ throw new Error(`Order line ${line.LineNumber}: a negative quantity is only valid on a reversal line. ` +
1078
+ `Set ReversesOrderLineID to the line being reversed, or use a positive quantity.`);
1079
+ }
1080
+ return false;
1081
+ }
1082
+ const provider = this.ProviderToUse;
1083
+ const user = this.ContextCurrentUser;
1084
+ // Exclude THIS line from the already-reversed total. On a re-save it is already in the
1085
+ // database, and counting it would make the line refuse itself.
1086
+ const context = await LoadReversalContext(reverses, provider, user, line.ID ? [line.ID] : []);
1087
+ if (!context) {
1088
+ throw new Error(`Order line ${line.LineNumber} reverses order line ${reverses}, which does not exist. ` +
1089
+ `A reversal pointing at nothing cannot be validated against anything — the quantity ` +
1090
+ `and price it claims to unwind have no source.`);
1091
+ }
1092
+ // In-memory siblings on THIS order count too. Two reversal lines against one origin, saved
1093
+ // together, are each within the original while their sum is not — and neither is in the
1094
+ // database yet for `LoadReversalContext` to have seen.
1095
+ let siblingReversed = 0;
1096
+ for (const other of this.Lines.Items) {
1097
+ if (other === line)
1098
+ continue;
1099
+ const otherReverses = other.ReversesOrderLineID;
1100
+ if (otherReverses && uuidKey(otherReverses) === uuidKey(reverses)) {
1101
+ siblingReversed += Math.abs(Number(other.Quantity ?? 0));
1102
+ }
1103
+ }
1104
+ const refusal = ValidateReversal({ ProductID: line.ProductID, Quantity: Number(line.Quantity ?? 0) }, context.Origin, context.AlreadyReversed + siblingReversed);
1105
+ if (refusal) {
1106
+ throw new Error(`Order line ${line.LineNumber}: ${refusal}`);
1107
+ }
1108
+ // Inherit the origin's terms, unless the caller stated their own. Same rule as pricing: a
1109
+ // stated value is a decision, and resolution only ever fills a blank.
1110
+ const terms = InheritedTerms(context.Origin, Number(line.Quantity ?? 0));
1111
+ const priceField = line.GetFieldByName('UnitPrice');
1112
+ if (!(priceField?.Dirty === true || (line.UnitPrice ?? 0) > 0)) {
1113
+ line.UnitPrice = terms.UnitPrice;
1114
+ }
1115
+ const discountField = line.GetFieldByName('DiscountPct');
1116
+ if (!(discountField?.Dirty === true || (line.DiscountPct ?? 0) > 0)) {
1117
+ line.DiscountPct = terms.DiscountPct;
1118
+ }
1119
+ // THE ALLOCATED DISCOUNT, PROPORTIONALLY. Without this a line that sold 4 x 100 less a 50
1120
+ // order-level promotion — 350 actually paid — refunds 400, and the difference is simply given
1121
+ // away against a perfectly balanced journal entry. `DiscountPct` was carried through from the
1122
+ // start and this was not, which is why RT7 passed while the defect was live.
1123
+ const amountField = line.GetFieldByName('DiscountAmount');
1124
+ if (!(amountField?.Dirty === true || (line.DiscountAmount ?? 0) > 0)) {
1125
+ line.DiscountAmount = terms.DiscountAmount;
1126
+ }
1127
+ return true;
1128
+ }
1129
+ /**
1130
+ * Record WHY a line owes no tax, as a zero-amount price component (D73).
1131
+ *
1132
+ * A zero tax line looks identical whether the product was untaxable, we had no nexus, or the
1133
+ * buyer held a certificate — and those are different facts. An auditor asking "why was no tax
1134
+ * charged on this line" needs the reason, not the total, and the component trail is where the
1135
+ * rest of the line's reasoning already lives.
1136
+ */
1137
+ async saveTaxReasons(persisted) {
1138
+ if (!this._taxReasons.size)
1139
+ return;
1140
+ const provider = this.ProviderToUse;
1141
+ const user = this.ContextCurrentUser;
1142
+ for (const [index, reason] of this._taxReasons) {
1143
+ const line = persisted[index];
1144
+ if (!line?.ID)
1145
+ continue;
1146
+ const row = await provider.GetEntityObject('MJ_BizApps_Orders: Order Line Price Components', user);
1147
+ row.NewRecord();
1148
+ row.OrderLineID = line.ID;
1149
+ row.Sequence = 900;
1150
+ row.ComponentType = 'Tax';
1151
+ row.Label = `no tax — ${reason}`;
1152
+ row.Amount = 0;
1153
+ row.RunningTotal = Number(line.LineTotalNet ?? 0);
1154
+ if (!(await row.Save())) {
1155
+ throw new Error(`Failed to record why line ${line.LineNumber} owes no tax: ` +
1156
+ `${row.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1157
+ }
1158
+ }
1159
+ this._taxReasons.clear();
1160
+ }
1161
+ /**
1162
+ * Write the price decomposition for lines that were priced by the engine (D69).
1163
+ *
1164
+ * Runs AFTER the lines are saved, because a component points at a line that must already exist.
1165
+ * Pricing disputes are inevitable and "the system computed it" is not an answer to a customer or
1166
+ * an auditor, so the reasoning is stored rather than recomputed later against rules that may by
1167
+ * then have changed.
1168
+ */
1169
+ async savePriceComponents(options) {
1170
+ if (!this._priceComponents.size)
1171
+ return;
1172
+ const provider = this.ProviderToUse;
1173
+ const user = this.ContextCurrentUser;
1174
+ const md = new Metadata();
1175
+ for (const [line, resolved] of this._priceComponents) {
1176
+ let seq = 0;
1177
+ for (const c of resolved.Components) {
1178
+ const row = await provider.GetEntityObject('MJ_BizApps_Orders: Order Line Price Components', user);
1179
+ row.NewRecord();
1180
+ row.OrderLineID = line.ID;
1181
+ row.Sequence = seq++;
1182
+ row.ComponentType = c.ComponentType;
1183
+ row.Label = c.Label;
1184
+ row.Amount = c.Amount;
1185
+ row.RunningTotal = c.RunningTotal;
1186
+ if (c.SourceEntityName && c.SourceRecordID) {
1187
+ const ent = md.EntityByName(c.SourceEntityName);
1188
+ if (ent) {
1189
+ row.SourceEntityID = ent.ID;
1190
+ row.SourceRecordID = c.SourceRecordID;
1191
+ }
1192
+ }
1193
+ if (!(await row.Save(options))) {
1194
+ throw new Error(`Failed to record the price breakdown for line ${line.LineNumber}: ` +
1195
+ `${row.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1196
+ }
1197
+ }
1198
+ }
1199
+ this._priceComponents.clear();
1200
+ }
1201
+ /**
1202
+ * Stamp an event line's service period from the EVENT's own dates (D-EVENT).
1203
+ *
1204
+ * WHY THIS IS NOT LEFT TO THE CALLER
1205
+ * An event product is bought in advance and earned on the day it happens: the money is deferred
1206
+ * revenue until then. That behaviour needs `ServicePeriodStart`/`End` — `AllBackEnd` recognizes
1207
+ * 100% on the END date, and `RequireServicePeriod` refuses to run without one.
1208
+ *
1209
+ * Leaving those dates to whoever creates the line means the recognition date for a conference is
1210
+ * hand-typed on every ticket sold, and a typo silently books revenue in the wrong period. The
1211
+ * event already knows when it is. Reading it from `EventProduct` makes the correct answer the
1212
+ * default and the ticket line carry no date at all.
1213
+ *
1214
+ * An explicitly-set period WINS and is never overwritten: a line covering only part of a
1215
+ * multi-day event, or a deliberate override, is a legitimate thing to express. Subscription
1216
+ * lines are untouched — their period comes from the term, which is decided later and is
1217
+ * authoritative over anything typed (see `materializeSubscriptions`).
1218
+ *
1219
+ * A product with no `EventProduct` row is simply not an event; nothing happens.
1220
+ */
1221
+ async applyEventServicePeriod(line) {
1222
+ if (!line.ProductID)
1223
+ return;
1224
+ const rv = new RunView(this.ProviderToUse);
1225
+ const res = await rv.RunView({
1226
+ EntityName: EVENT_PRODUCT_ENTITY,
1227
+ ExtraFilter: `ID='${line.ProductID}'`,
1228
+ Fields: ['EventStartsAt', 'EventEndsAt'],
1229
+ ResultType: 'simple',
1230
+ BypassCache: true,
1231
+ }, this.ContextCurrentUser);
1232
+ const event = res?.Results?.[0];
1233
+ if (!event)
1234
+ return;
1235
+ // NOTE: EventProduct.Capacity is deliberately NOT enforced here — see
1236
+ // plans/archive/bizapps-orders-master.md §21b. A correct check cannot be written
1237
+ // against these tables alone.
1238
+ // Dates only when the line does not state its own — an explicitly-set period WINS.
1239
+ if (line.ServicePeriodStart || line.ServicePeriodEnd)
1240
+ return;
1241
+ if (!event.EventStartsAt)
1242
+ return;
1243
+ const start = new Date(event.EventStartsAt);
1244
+ // A single-day event has no end date; the period is that one day, so recognition lands on
1245
+ // the event itself rather than being left without an end for AllBackEnd to aim at.
1246
+ const end = event.EventEndsAt ? new Date(event.EventEndsAt) : start;
1247
+ line.ServicePeriodStart = start;
1248
+ line.ServicePeriodEnd = end;
1249
+ }
1250
+ /**
1251
+ * Build one draft per line, submit them as ONE set, then stamp each line with its entry.
1252
+ * Every step is inside the caller's transaction.
1253
+ */
1254
+ async bookLines(lines, options, subs) {
1255
+ const provider = this.ProviderToUse;
1256
+ const user = this.ContextCurrentUser;
1257
+ const unbooked = lines.filter((l) => !l.JournalEntryID);
1258
+ if (unbooked.length === 0)
1259
+ return;
1260
+ const factory = new OrderJournalEntryFactory(await this.buildResolver(provider, user), this.entityIDFor(ORDER_LINE_ENTITY), this.entityIDFor(SUBSCRIPTION_TERM_ENTITY), this.entityIDFor(CHARGE_TYPE_ENTITY), provider, user);
1261
+ const drafts = await factory.BuildDrafts(this, unbooked, subs?.TermsByLine, subs?.RecognitionMonthsByLine);
1262
+ // An order can legitimately produce NO entries: every line fully comped, so nothing to
1263
+ // debit or credit. Accounting refuses an empty draft set, quite correctly, so the call is
1264
+ // skipped rather than the order being refused for having no ledger impact.
1265
+ if (drafts.length === 0)
1266
+ return;
1267
+ const result = await this.submitDrafts(drafts, provider, user);
1268
+ if (!result.Success) {
1269
+ const detail = (result.Errors ?? [])
1270
+ .map((e) => {
1271
+ const which = e.DraftIndex !== undefined
1272
+ ? ` (order line ${unbooked.find((l) => l.ID === drafts[e.DraftIndex]?.OrderLineID)?.LineNumber ??
1273
+ e.DraftIndex})`
1274
+ : '';
1275
+ return `${e.Code}${which}: ${e.Message}`;
1276
+ })
1277
+ .join('; ');
1278
+ throw new Error(`Journal entry booking failed — no entries were written. ${detail}`);
1279
+ }
1280
+ await this.stampJournalEntryIDs(drafts, result, options);
1281
+ }
1282
+ /**
1283
+ * Invoke accounting's SET operation. Resolved through MJ's class factory by key so this
1284
+ * package does not hard-depend on the accounting server package at build time.
1285
+ */
1286
+ async submitDrafts(drafts, provider, user) {
1287
+ const op = MJGlobal.Instance.ClassFactory.CreateInstance(BaseRemotableOperation, 'Accounting.CreateJournalEntries');
1288
+ if (!op) {
1289
+ throw new Error(`The 'Accounting.CreateJournalEntries' operation is not registered. The BizApps ` +
1290
+ `Accounting server package must be loaded before orders can book journal entries.`);
1291
+ }
1292
+ const result = await op.Execute({ Drafts: drafts.map((d) => d.Draft) }, { provider, user });
1293
+ // The envelope reports transport/authorization failure; the payload reports the
1294
+ // accounting-domain outcome. Both must be checked — a successful call can still carry
1295
+ // a failed booking.
1296
+ if (!result.Success) {
1297
+ throw new Error(`Accounting.CreateJournalEntries did not execute: ${result.ErrorMessage ?? result.ResultCode ?? 'unknown error'}`);
1298
+ }
1299
+ if (!result.Output) {
1300
+ throw new Error(`Accounting.CreateJournalEntries returned no payload.`);
1301
+ }
1302
+ return result.Output;
1303
+ }
1304
+ /**
1305
+ * Stamp each line with its BOOKING entry (NULL→value-once; trigger 51008 enforces the same
1306
+ * rule independently). Recognition entries are forward-dated releases — they belong to the
1307
+ * schedule, not to the line's booking pointer, so they are skipped here.
1308
+ */
1309
+ async stampJournalEntryIDs(drafts, result, options) {
1310
+ const provider = this.ProviderToUse;
1311
+ const results = result.Results ?? [];
1312
+ for (let i = 0; i < drafts.length; i++) {
1313
+ if (!drafts[i].IsBooking)
1314
+ continue;
1315
+ const jeID = results[i]?.JournalEntryID;
1316
+ if (!jeID) {
1317
+ throw new Error(`Accounting reported success but returned no journal entry for order line index ${i}.`);
1318
+ }
1319
+ // Prefer the in-memory line from this.Lines (already an instantiated IS-A subclass if applicable),
1320
+ // falling back to loading through the entity's OWN provider to see uncommitted transactions.
1321
+ let line = this.Lines.Items.find((l) => UUIDsEqual(l.ID, drafts[i].OrderLineID));
1322
+ if (!line) {
1323
+ const loadedLine = await provider.GetEntityObject(ORDER_LINE_ENTITY, this.ContextCurrentUser);
1324
+ if (await loadedLine.Load(drafts[i].OrderLineID)) {
1325
+ line = loadedLine;
1326
+ }
1327
+ }
1328
+ if (!line) {
1329
+ throw new Error(`Order line with ID ${drafts[i].OrderLineID} could not be loaded to stamp JournalEntryID.`);
1330
+ }
1331
+ // JournalEntryID lives on Order Line. Event/Subscription/etc. IS-A children
1332
+ // in this.Lines would otherwise Save() as the leaf, whose clean-leaf
1333
+ // finalizeSave used to throw on parent virtuals (OrderHeader).
1334
+ const stampTarget = this.resolveOrderLineForStamp(line);
1335
+ stampTarget.JournalEntryID = jeID;
1336
+ const saveOptions = new EntitySaveOptions();
1337
+ if (options) {
1338
+ Object.assign(saveOptions, options);
1339
+ }
1340
+ saveOptions.IsParentEntitySave = true;
1341
+ const saved = await stampTarget.Save(saveOptions);
1342
+ if (!saved) {
1343
+ throw new Error(`Failed to stamp JournalEntryID on order line ${stampTarget.LineNumber}: ${ExtractEntityErrorMessage(stampTarget)}`);
1344
+ }
1345
+ }
1346
+ }
1347
+ /**
1348
+ * Walk up the IS-A chain to the Order Line that owns `JournalEntryID`.
1349
+ * Event Order Line / Subscription Order Line instances in `this.Lines` are
1350
+ * children of that row; stamping on the parent and saving with
1351
+ * `IsParentEntitySave` skips leaf delegation.
1352
+ */
1353
+ resolveOrderLineForStamp(line) {
1354
+ let current = line;
1355
+ while (current.ISAParent) {
1356
+ current = current.ISAParent;
1357
+ }
1358
+ return current;
1359
+ }
1360
+ // ─── Subscriptions (D45/D46) ───────────────────────────────────────────────
1361
+ /**
1362
+ * For every subscription line, apply the type's rules and materialize the Subscription (find,
1363
+ * extend, reactivate, or create) plus the SubscriptionTerm this purchase bought.
1364
+ *
1365
+ * Runs BEFORE booking so the recognition entries can anchor to the term and use its
1366
+ * anchored/prorated window — a calendar-anchored membership bought in July does NOT recognize
1367
+ * over July→July; it recognizes over July→Dec at a prorated amount.
1368
+ *
1369
+ * All rule evaluation is delegated to `SubscriptionBehavior`, which is pure. This method does
1370
+ * the persistence, inside the caller's transaction.
1371
+ */
1372
+ async materializeSubscriptions(lines, decisions, options) {
1373
+ const provider = this.ProviderToUse;
1374
+ const user = this.ContextCurrentUser;
1375
+ const out = { TermsByLine: new Map(), RecognitionMonthsByLine: new Map() };
1376
+ if (decisions.size === 0)
1377
+ return out;
1378
+ // The decisions were made against the PENDING line objects; re-key them by ID so they can
1379
+ // be matched to the freshly loaded, now-persisted lines.
1380
+ //
1381
+ // LOWERCASED: a GUID that round-trips through SQL Server comes back uppercased, so keying
1382
+ // on the raw value silently misses every lookup — the subscription would be created with no
1383
+ // term attached, and the recognition schedule would then fail for want of a coverage window.
1384
+ const byLineID = new Map();
1385
+ for (const [pending, decided] of decisions) {
1386
+ if (pending.ID)
1387
+ byLineID.set(uuidKey(pending.ID), decided);
1388
+ }
1389
+ // Subscriptions created during THIS pass, so a later line extending a sibling's
1390
+ // subscription can resolve the placeholder to the id that now exists.
1391
+ const createdByDedupeKey = new Map();
1392
+ for (const line of lines) {
1393
+ const decided = byLineID.get(uuidKey(line.ID));
1394
+ if (!decided)
1395
+ continue;
1396
+ const { Product: product, Rules: rules, Decision: decision } = decided;
1397
+ // A line extending a subscription an EARLIER LINE of this order is creating
1398
+ // carries a placeholder, because that subscription did not exist when the
1399
+ // decision was made. Swap in the real ID now that the sibling has written it.
1400
+ if (decision.SubscriptionID === PENDING_SIBLING_ID) {
1401
+ const sibling = createdByDedupeKey.get(decided.DedupeKey);
1402
+ if (!sibling) {
1403
+ throw new Error(`Order line ${line.LineNumber} (${product.Name}) extends a subscription from an ` +
1404
+ `earlier line of this order, but that line did not create one.`);
1405
+ }
1406
+ decision.SubscriptionID = sibling;
1407
+ }
1408
+ const subscriptionID = decision.Action === 'CreateNew'
1409
+ ? await this.createSubscription(line, product, rules, decision, decided.Subscriber, options)
1410
+ : await this.touchExistingSubscription(decision, !!line.RenewsSubscriptionID, options);
1411
+ // Remember it so a later line for the same subscription resolves above.
1412
+ if (decision.Action === 'CreateNew')
1413
+ createdByDedupeKey.set(decided.DedupeKey, subscriptionID);
1414
+ const term = decision.Term;
1415
+ // The LINE is the authority on price — `savePendingLines` already inserted it at the
1416
+ // prorated quantity. Taking the term's amount from it makes booking (line net), the
1417
+ // term, and the recognition schedule reconcile exactly; three numbers that must agree
1418
+ // or deferred revenue never clears to zero.
1419
+ term.Amount = line.LineTotalNet ?? term.Amount;
1420
+ const termEntity = await provider.GetEntityObject(SUBSCRIPTION_TERM_ENTITY, user);
1421
+ termEntity.NewRecord();
1422
+ termEntity.SubscriptionID = subscriptionID;
1423
+ termEntity.TermNumber = term.TermNumber;
1424
+ termEntity.OrderLineID = line.ID;
1425
+ termEntity.StartDate = term.StartDate;
1426
+ termEntity.EndDate = term.EndDate;
1427
+ termEntity.Amount = term.Amount;
1428
+ termEntity.IsProrated = term.IsProrated;
1429
+ termEntity.ProrationFactor = term.ProrationFactor;
1430
+ // Frozen at purchase: later changes to the product's rules must never restate a
1431
+ // term that has already been booked.
1432
+ termEntity.RevenueRecognitionTypeID = product.RevenueRecognitionTypeID;
1433
+ termEntity.Status = 'Active';
1434
+ if (!(await termEntity.Save(options))) {
1435
+ throw new Error(`Failed to create the subscription term for order line ${line.LineNumber}: ` +
1436
+ `${termEntity.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1437
+ }
1438
+ // The term is the coverage window the schedule must follow, and its cadence decides
1439
+ // how many slices that window produces.
1440
+ out.TermsByLine.set(line.ID, {
1441
+ ID: termEntity.ID,
1442
+ StartDate: term.StartDate,
1443
+ EndDate: term.EndDate,
1444
+ Amount: term.Amount,
1445
+ });
1446
+ out.RecognitionMonthsByLine.set(line.ID, decided.Behavior.RecognitionMonths(rules));
1447
+ // The line's stored service period reflects the TERM, not what a user typed.
1448
+ line.ServicePeriodStart = term.StartDate;
1449
+ line.ServicePeriodEnd = term.EndDate;
1450
+ // The forward link, which nothing was writing. Subscription.OrderLineID
1451
+ // recorded the reverse, so the subscription knew its line while the line
1452
+ // did not know its subscription — and PreviewConfirmOperation reads
1453
+ // exactly this field to show what a confirm will create, so the pre-flight
1454
+ // could never show subscription detail for a line. Set here because the
1455
+ // line is already being saved on the next statement; it costs no extra write.
1456
+ line.SubscriptionID = subscriptionID;
1457
+ const serverLine = line;
1458
+ serverLine.BypassBookedCheck = true;
1459
+ await line.Save(options);
1460
+ }
1461
+ return out;
1462
+ }
1463
+ /**
1464
+ * Evaluate the subscription rules for every pending line, BEFORE any of them is inserted.
1465
+ *
1466
+ * Pure with respect to this app's tables — it reads the catalog and any existing subscription,
1467
+ * then asks the behaviour what to do. Nothing is written, so a rules rejection aborts the
1468
+ * confirm before a single line exists.
1469
+ */
1470
+ async decideSubscriptions() {
1471
+ const out = new Map();
1472
+ const subLines = await this.subscriptionLines([...this.Lines.Items]);
1473
+ if (subLines.length === 0)
1474
+ return out;
1475
+ // Settings drive whether the organization is inferred at all, so load the cache once here
1476
+ // rather than per line.
1477
+ await OrdersSettings.Load(this.ProviderToUse, this.ContextCurrentUser);
1478
+ // What an EARLIER line of this same order has already decided to create,
1479
+ // keyed the way the type defines a duplicate. Without this the rules only
1480
+ // ever see the database, so two lines for one subscription both decide
1481
+ // "create" — see PENDING_SIBLING_ID.
1482
+ const pendingSiblings = new Map();
1483
+ for (const { line, product, rules } of subLines) {
1484
+ const behavior = this.behaviorFor(rules);
1485
+ let subscriber = await this.withInferredOrganization(this.resolveSubscriber(line));
1486
+ const identity = behavior.DedupeIdentity(rules, subscriber);
1487
+ const dedupeKey = `${product.ID}|${identity.OrganizationID ?? ''}|${identity.PersonID ?? ''}`;
1488
+ // An explicitly named subscription wins; then a sibling line of THIS order;
1489
+ // then one already in the database for this subscriber and product (D62).
1490
+ const existing = line.RenewsSubscriptionID
1491
+ ? await this.loadSubscriptionState(`ID='${line.RenewsSubscriptionID}'`)
1492
+ : (pendingSiblings.get(dedupeKey) ??
1493
+ (await this.findExistingSubscription(product.ID, identity)));
1494
+ // NAMING a subscription IS the statement of who the subscriber is. Requiring the line to
1495
+ // restate it would make renewing a seat impossible without repeating the person, and any
1496
+ // mismatch between the two would be a silent contradiction. The target wins.
1497
+ if (line.RenewsSubscriptionID && existing) {
1498
+ subscriber = {
1499
+ OrganizationID: existing.HolderOrganizationID ?? null,
1500
+ PersonID: existing.BeneficiaryPersonID ?? null,
1501
+ };
1502
+ }
1503
+ const decision = behavior.Decide({
1504
+ Rules: rules,
1505
+ PurchaseDate: this.OrderDate ? new Date(this.OrderDate) : new Date(),
1506
+ // The line is not saved yet, so `LineTotalNet` is not computed — derive the same
1507
+ // figure OrderLineEntityServer will: quantity × price, less the discount.
1508
+ Amount: this.pendingLineNet(line),
1509
+ Existing: existing,
1510
+ Subscriber: subscriber,
1511
+ // A renewal continues the NAMED subscription rather than starting one, so the
1512
+ // concurrency rule must not refuse it (D55).
1513
+ IsRenewal: !!line.RenewsSubscriptionID,
1514
+ });
1515
+ if (decision.Action === 'Reject') {
1516
+ // A rules violation fails the WHOLE confirm — booking is all-or-none, and a
1517
+ // silently-dropped subscription would leave a paid-for line with no coverage.
1518
+ throw new Error(`Order line ${line.LineNumber} (${product.Name}) cannot be subscribed: ${decision.RejectReason}`);
1519
+ }
1520
+ // Record what this line will produce, so a LATER line covering the same
1521
+ // subscription extends it (or is refused) instead of creating a second.
1522
+ // A real renewal target is left alone: it is already in the database, so
1523
+ // the ordinary lookup finds it.
1524
+ if (decision.Term && !line.RenewsSubscriptionID) {
1525
+ pendingSiblings.set(dedupeKey, {
1526
+ ID: existing?.ID ?? PENDING_SIBLING_ID,
1527
+ Status: 'Active',
1528
+ HolderOrganizationID: subscriber.OrganizationID,
1529
+ BeneficiaryPersonID: subscriber.PersonID,
1530
+ LatestTermEnd: decision.Term.EndDate,
1531
+ LatestTermNumber: decision.Term.TermNumber,
1532
+ });
1533
+ }
1534
+ out.set(line, {
1535
+ Product: product,
1536
+ Rules: rules,
1537
+ Decision: decision,
1538
+ Behavior: behavior,
1539
+ Subscriber: subscriber,
1540
+ DedupeKey: dedupeKey,
1541
+ });
1542
+ }
1543
+ return out;
1544
+ }
1545
+ /**
1546
+ * WHO a line is for: the line's ship-to, falling back to the order header (D61).
1547
+ *
1548
+ * Ship-to on a line means "where this goes" for a physical product and "who this is for" when
1549
+ * there is nothing to ship. A subscription line therefore reads its subscriber from the same
1550
+ * fields a shipped line reads its destination from — one question, two kinds of answer.
1551
+ *
1552
+ * The fallback is per-side, not all-or-nothing: a line may name only a person (a seat bought
1553
+ * under the order's customer organization) and still inherit that organization from the header.
1554
+ */
1555
+ resolveSubscriber(line) {
1556
+ return {
1557
+ OrganizationID: line.ShipToOrganizationID ?? this.ShipToOrganizationID ?? this.BillToOrganizationID ?? null,
1558
+ PersonID: line.ShipToPersonID ?? this.ShipToPersonID ?? this.BillToPersonID ?? null,
1559
+ };
1560
+ }
1561
+ /**
1562
+ * Fill in the organization from the person's affiliation, when it was left blank (D64).
1563
+ *
1564
+ * Only ever ADDS: an organization that was stated — on the line, the order's ship-to, or its
1565
+ * customer — is never second-guessed. And it only runs when the setting says so, so a
1566
+ * deployment that would rather not infer anything simply turns it off and blank stays blank.
1567
+ */
1568
+ async withInferredOrganization(subscriber) {
1569
+ if (subscriber.OrganizationID)
1570
+ return subscriber;
1571
+ if (!subscriber.PersonID)
1572
+ return subscriber;
1573
+ if (!OrdersSettings.AutoPopulateOrganizationFromPerson)
1574
+ return subscriber;
1575
+ const asOf = this.OrderDate ? new Date(this.OrderDate) : new Date();
1576
+ const inferred = await this.organizationAsOf(subscriber.PersonID, asOf);
1577
+ return inferred ? { ...subscriber, OrganizationID: inferred } : subscriber;
1578
+ }
1579
+ /**
1580
+ * The organization a person belonged to AS OF the order date (D64).
1581
+ *
1582
+ * `Person` has no organization column — bizapps-common models affiliation as a dated
1583
+ * `Relationship` (FromPersonID → ToOrganizationID, with StartDate/EndDate/Status). So this is a
1584
+ * point-in-time question, and the answer legitimately changes when someone moves employer. That
1585
+ * is exactly why the result is STAMPED onto the order rather than resolved on read: deriving it
1586
+ * later would silently rewrite the history of an order that is otherwise immutable once booked.
1587
+ *
1588
+ * The rule, per Amith:
1589
+ * zero qualifying affiliations → leave blank. That IS a personal order — no flag needed,
1590
+ * because "person, no organization" already says it.
1591
+ * exactly one → use it.
1592
+ * more than one → the most recent by StartDate. A person can hold several at
1593
+ * once (employee here, board member there), and Relationship
1594
+ * has no uniqueness constraint, so this case is normal rather
1595
+ * than exceptional and needs a stated rule instead of a guess.
1596
+ *
1597
+ * Which relationship types qualify is a SETTING, defaulting to `Employee` — being a `Vendor` to
1598
+ * an organization must not make it your bill-to.
1599
+ */
1600
+ async organizationAsOf(personID, asOf) {
1601
+ const types = OrdersSettings.OrganizationAffiliationRelationshipTypes;
1602
+ if (types.length === 0)
1603
+ return null;
1604
+ const rv = new RunView(this.ProviderToUse);
1605
+ const quoted = types.map((t) => `'${t.replace(/'/g, "''")}'`).join(',');
1606
+ const date = asOf.toISOString().slice(0, 10);
1607
+ const result = await rv.RunView({
1608
+ EntityName: RELATIONSHIP_ENTITY,
1609
+ ExtraFilter: `FromPersonID='${personID}' AND ToOrganizationID IS NOT NULL ` +
1610
+ `AND Status='Active' ` +
1611
+ `AND (StartDate IS NULL OR StartDate <= '${date}') ` +
1612
+ `AND (EndDate IS NULL OR EndDate >= '${date}') ` +
1613
+ `AND RelationshipTypeID IN (SELECT ID FROM ${COMMON_SCHEMA}.RelationshipType WHERE Name IN (${quoted}))`,
1614
+ Fields: ['ToOrganizationID', 'StartDate'],
1615
+ // Most recent affiliation first. NULL StartDate sorts last: an undated relationship
1616
+ // is weaker evidence than one that says when it began.
1617
+ OrderBy: 'StartDate DESC',
1618
+ ResultType: 'simple',
1619
+ BypassCache: true,
1620
+ }, this.ContextCurrentUser);
1621
+ return result?.Results?.[0]?.ToOrganizationID ?? null;
1622
+ }
1623
+ /** Lines whose product carries a subscription type, joined to their rules. */
1624
+ async subscriptionLines(lines) {
1625
+ const rv = new RunView(this.ProviderToUse);
1626
+ const ids = [...new Set(lines.map(l => l.ProductID))].map(id => `'${id}'`).join(',');
1627
+ if (!ids)
1628
+ return [];
1629
+ const prod = await rv.RunView({
1630
+ EntityName: PRODUCT_ENTITY,
1631
+ ExtraFilter: `ID IN (${ids}) AND SubscriptionTypeID IS NOT NULL`,
1632
+ Fields: ['ID', 'Name', 'SubscriptionTypeID', 'RevenueRecognitionTypeID'],
1633
+ ResultType: 'simple',
1634
+ }, this.ContextCurrentUser);
1635
+ const products = new Map((prod?.Results ?? []).map(p => [uuidKey(p.ID), p]));
1636
+ if (products.size === 0)
1637
+ return [];
1638
+ const types = await rv.RunView({ EntityName: SUBSCRIPTION_TYPE_ENTITY, ResultType: 'simple' }, this.ContextCurrentUser);
1639
+ const rulesByID = new Map((types?.Results ?? []).map(t => [uuidKey(t.ID), t]));
1640
+ const out = [];
1641
+ for (const line of lines) {
1642
+ const product = products.get(uuidKey(line.ProductID));
1643
+ if (!product)
1644
+ continue;
1645
+ // A REVERSAL line buys nothing — it unwinds a purchase (D16). Materializing here would
1646
+ // create a second subscription (and a second term) every time one was cancelled, which
1647
+ // is the exact opposite of what the line means.
1648
+ if (line.ReversesOrderLineID || (line.Quantity ?? 0) < 0)
1649
+ continue;
1650
+ const rules = rulesByID.get(uuidKey(product.SubscriptionTypeID));
1651
+ if (!rules) {
1652
+ throw new Error(`Product '${product.Name}' names a subscription type that was not found.`);
1653
+ }
1654
+ out.push({ line, product, rules });
1655
+ }
1656
+ return out;
1657
+ }
1658
+ /**
1659
+ * The behaviour object: the base class when the type has no driver (the common case — the
1660
+ * columns ARE the rules, D45), or a registered subclass when one is named.
1661
+ */
1662
+ behaviorFor(rules) {
1663
+ if (!rules.DriverClass)
1664
+ return new SubscriptionBehavior();
1665
+ const driver = MJGlobal.Instance.ClassFactory.CreateInstance(SubscriptionBehavior, rules.DriverClass);
1666
+ if (!driver) {
1667
+ throw new Error(`Subscription type '${rules.Code}' names driver '${rules.DriverClass}', which is not ` +
1668
+ `registered. Register a SubscriptionBehavior subclass under that key.`);
1669
+ }
1670
+ return driver;
1671
+ }
1672
+ /**
1673
+ * The subscription this purchase attaches to.
1674
+ *
1675
+ * A RENEWAL names its subscription outright (D55) — resolving by (customer, product) instead
1676
+ * would be a guess, and would pick the wrong one whenever a customer holds two subscriptions to
1677
+ * the same product under an `AllowMultiple` type.
1678
+ */
1679
+ async findExistingSubscription(productID, identity) {
1680
+ // Match on exactly the axes the BenefitModel says define a duplicate. An org-members type
1681
+ // ignores the person entirely (one company membership, however many employees); a seat type
1682
+ // matches BOTH, so two seats for two people never collide.
1683
+ const clauses = [];
1684
+ clauses.push(identity.OrganizationID
1685
+ ? `HolderOrganizationID='${identity.OrganizationID}'`
1686
+ : `HolderOrganizationID IS NULL`);
1687
+ clauses.push(identity.PersonID ? `BeneficiaryPersonID='${identity.PersonID}'` : `BeneficiaryPersonID IS NULL`);
1688
+ if (!identity.OrganizationID && !identity.PersonID)
1689
+ return null;
1690
+ return this.loadSubscriptionState(`ProductID='${productID}' AND ${clauses.join(' AND ')}`);
1691
+ }
1692
+ /** Load a subscription plus the end and number of its latest term, by whatever filter. */
1693
+ async loadSubscriptionState(filter) {
1694
+ const rv = new RunView(this.ProviderToUse);
1695
+ const res = await rv.RunView({
1696
+ EntityName: SUBSCRIPTION_ENTITY,
1697
+ ExtraFilter: filter,
1698
+ Fields: ['ID', 'Status', 'HolderOrganizationID', 'BeneficiaryPersonID'],
1699
+ OrderBy: '__mj_CreatedAt DESC',
1700
+ MaxRows: 1,
1701
+ ResultType: 'simple',
1702
+ BypassCache: true,
1703
+ }, this.ContextCurrentUser);
1704
+ const sub = res?.Results?.[0];
1705
+ if (!sub)
1706
+ return null;
1707
+ const terms = await rv.RunView({
1708
+ EntityName: SUBSCRIPTION_TERM_ENTITY,
1709
+ ExtraFilter: `SubscriptionID='${sub.ID}'`,
1710
+ Fields: ['EndDate', 'TermNumber'],
1711
+ OrderBy: 'TermNumber DESC',
1712
+ MaxRows: 1,
1713
+ ResultType: 'simple',
1714
+ BypassCache: true,
1715
+ }, this.ContextCurrentUser);
1716
+ const latest = terms?.Results?.[0];
1717
+ return {
1718
+ ID: sub.ID,
1719
+ Status: sub.Status,
1720
+ HolderOrganizationID: sub.HolderOrganizationID,
1721
+ BeneficiaryPersonID: sub.BeneficiaryPersonID,
1722
+ LatestTermEnd: latest?.EndDate ? new Date(latest.EndDate) : null,
1723
+ LatestTermNumber: latest?.TermNumber ?? 0,
1724
+ };
1725
+ }
1726
+ async createSubscription(line, product, rules, decision, subscriber, options) {
1727
+ const provider = this.ProviderToUse;
1728
+ const sub = await provider.GetEntityObject(SUBSCRIPTION_ENTITY, this.ContextCurrentUser);
1729
+ sub.NewRecord();
1730
+ sub.SubscriptionNumber = await this.assignSubscriptionNumber();
1731
+ // The LINE's company, not the order's. A subscription is recurring revenue
1732
+ // for whoever sells it, and on a mixed order that is not the order's owner:
1733
+ // an order carrying a membership from each of two companies produced two
1734
+ // subscriptions that BOTH landed in the header's ledger, so one company held
1735
+ // the other's subscriber. The journal entries in this same transaction are
1736
+ // already per-line and single-company (D10) — this brings subscriptions onto
1737
+ // the same footing rather than leaving the two records disagreeing about who
1738
+ // sold what. Falls back to the order's company only if a line somehow has
1739
+ // none, which savePendingLines does not allow.
1740
+ sub.CompanyID = line.CompanyID ?? this.CompanyID;
1741
+ // The BIRTH line (D39/D40) — which purchase brought this subscription into existence.
1742
+ // Renewals append terms that carry their own OrderLineID; this one never changes.
1743
+ sub.OrderLineID = line.ID;
1744
+ sub.SubscriptionTypeID = rules.ID;
1745
+ sub.ProductID = product.ID;
1746
+ // The RESOLVED subscriber, which may differ from the order's customer: the customer pays,
1747
+ // the ship-to holds and benefits.
1748
+ sub.HolderOrganizationID = subscriber.OrganizationID;
1749
+ sub.BeneficiaryPersonID = subscriber.PersonID;
1750
+ sub.Status = rules.TrialDays > 0 ? 'Trialing' : 'Active';
1751
+ sub.StartDate = decision.Term.StartDate;
1752
+ sub.AutoRenew = rules.AutoRenewDefault;
1753
+ // A trial with no end date is not a trial. Without this, `Status='Trialing'` is a label
1754
+ // nothing can ever act on — no job can find trials about to expire.
1755
+ if (rules.TrialDays > 0) {
1756
+ const trialEnd = new Date(decision.Term.StartDate);
1757
+ trialEnd.setUTCDate(trialEnd.getUTCDate() + rules.TrialDays);
1758
+ sub.TrialEndDate = trialEnd;
1759
+ }
1760
+ if (!(await sub.Save(options))) {
1761
+ throw new Error(`Failed to create the subscription for '${product.Name}': ` +
1762
+ `${sub.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1763
+ }
1764
+ const subscriptionID = sub.ID;
1765
+ await this.logSubscriptionEvent(subscriptionID, rules.TrialDays > 0 ? 'TrialStarted' : 'Created', options);
1766
+ return subscriptionID;
1767
+ }
1768
+ /**
1769
+ * Append to the subscription's immutable lifecycle log.
1770
+ *
1771
+ * The log is the answer to "what happened to this membership, and when" — the question support
1772
+ * actually gets asked. Writing it at every transition is the only thing that makes the table
1773
+ * worth having; a `SubscriptionEvent` table nobody writes to is just schema.
1774
+ */
1775
+ async logSubscriptionEvent(subscriptionID,
1776
+ // DERIVED from the entity, never restated: `EventType` is a CHECK-constrained value list, so
1777
+ // CodeGen widens this union whenever a migration adds a value. A hand-copied union would
1778
+ // silently stop tracking it.
1779
+ eventType, options, data) {
1780
+ const provider = this.ProviderToUse;
1781
+ const event = await provider.GetEntityObject(SUBSCRIPTION_EVENT_ENTITY, this.ContextCurrentUser);
1782
+ event.NewRecord();
1783
+ event.SubscriptionID = subscriptionID;
1784
+ event.EventType = eventType;
1785
+ event.OccurredAt = new Date();
1786
+ event.RelatedOrderHeaderID = this.ID;
1787
+ if (data)
1788
+ event.EventData = JSON.stringify(data);
1789
+ if (!(await event.Save(options))) {
1790
+ // Inside the booking transaction: a lost lifecycle record is a silent hole in the
1791
+ // audit trail, so it fails the confirm rather than being swallowed.
1792
+ throw new Error(`Failed to log the '${eventType}' subscription event: ` +
1793
+ `${event.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1794
+ }
1795
+ }
1796
+ /** Extension or reactivation — the term is what changes; the subscription just re-activates. */
1797
+ async touchExistingSubscription(decision, isRenewal, options) {
1798
+ const provider = this.ProviderToUse;
1799
+ const sub = await provider.GetEntityObject(SUBSCRIPTION_ENTITY, CompositeKey.FromID(decision.SubscriptionID), this.ContextCurrentUser);
1800
+ if (decision.Action === 'Reactivate') {
1801
+ sub.Status = 'Active';
1802
+ sub.CanceledAt = null;
1803
+ sub.EndDate = null;
1804
+ sub.AutoRenew = true;
1805
+ if (!(await sub.Save(options))) {
1806
+ throw new Error(`Failed to reactivate subscription: ${sub.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1807
+ }
1808
+ }
1809
+ // 'Extended' is the CUSTOMER buying more coverage. The system renewing them under standing
1810
+ // authority logs 'RenewalOrderSpawned' from SpawnRenewalsOperation instead — same table,
1811
+ // opposite answers to "why is this member still here", and retention reporting needs both.
1812
+ // A renewal reaches this method too, so it must not also log the customer-side event. The
1813
+ // marker is now per-LINE (D61): one order can renew several subscriptions, so "is this a
1814
+ // renewal" is a question about the line, not the order.
1815
+ if (!isRenewal) {
1816
+ await this.logSubscriptionEvent(decision.SubscriptionID, decision.Action === 'Reactivate' ? 'Activated' : 'Extended', options, { TermNumber: decision.Term?.TermNumber, Action: decision.Action });
1817
+ }
1818
+ return decision.SubscriptionID;
1819
+ }
1820
+ /**
1821
+ * Mint the next `SUB-{seq}` from the SubscriptionSequence singleton.
1822
+ *
1823
+ * A subscription number is member-facing — it is the "membership number" someone reads over the
1824
+ * phone — so it gets its own counter rather than being derived from whichever order created it.
1825
+ */
1826
+ async assignSubscriptionNumber() {
1827
+ return `SUB-${String(await this.nextSequence('SubscriptionSequence')).padStart(6, '0')}`;
1828
+ }
1829
+ // ─── Numbering (D30) ───────────────────────────────────────────────────────
1830
+ /**
1831
+ * Mint the next `ORD-{seq}` from the OrderSequence singleton.
1832
+ *
1833
+ * The order IS the invoice (D2), so this is an A/R document number and auditors expect it to
1834
+ * be gap-conscious. Taken with UPDLOCK+HOLDLOCK inside the caller's transaction so concurrent
1835
+ * confirms serialize on the counter rather than colliding on the UNIQUE index.
1836
+ *
1837
+ * Currently a GLOBAL sequence per D30. Jeremy's global-vs-per-company call is still open; the
1838
+ * change would be a WHERE clause here plus a CompanyID on the sequence table.
1839
+ */
1840
+ async assignOrderNumber() {
1841
+ return `ORD-${String(await this.nextSequence('OrderSequence')).padStart(6, '0')}`;
1842
+ }
1843
+ /**
1844
+ * Take the next value from one of the singleton counter tables.
1845
+ *
1846
+ * Taken with UPDLOCK+HOLDLOCK inside the CALLER'S transaction, so concurrent confirms serialize
1847
+ * on the counter row rather than colliding on the UNIQUE index — and a confirm that rolls back
1848
+ * releases its number rather than burning it.
1849
+ *
1850
+ * `OUTPUT ... INTO` (not a bare `OUTPUT`): CodeGen puts an `__mj_UpdatedAt` trigger on every
1851
+ * table, and SQL Server forbids a bare OUTPUT clause on a table that has triggers.
1852
+ */
1853
+ async nextSequence(table) {
1854
+ const provider = this.ProviderToUse;
1855
+ const rows = (await provider.ExecuteSQL(`DECLARE @seq TABLE (Seq INT);
1856
+ UPDATE __mj_BizAppsOrders.${table} WITH (UPDLOCK, HOLDLOCK)
1857
+ SET NextSequenceNumber = NextSequenceNumber + 1
1858
+ OUTPUT deleted.NextSequenceNumber INTO @seq(Seq)
1859
+ WHERE ID = 1;
1860
+ SELECT Seq FROM @seq;`));
1861
+ const seq = rows?.[0]?.Seq;
1862
+ if (!seq) {
1863
+ throw new Error(`Could not obtain the next number from ${table} — its singleton row (ID=1) is missing. ` +
1864
+ `It is seeded by the baseline migration.`);
1865
+ }
1866
+ return seq;
1867
+ }
1868
+ // ─── Initial payment (D42) ─────────────────────────────────────────────────
1869
+ /**
1870
+ * Turn the order's captured payment INTENT into a real payment.
1871
+ *
1872
+ * `InitialPaymentTypeID`/`Amount`/`DetailID` are a convenience capture at order entry; on
1873
+ * confirm they become a PaymentHeader plus a PaymentLine applied to this order. The rollup
1874
+ * triggers then move AmountPaid/Balance/PaymentStatus on their own (D41) — nothing here
1875
+ * touches those fields.
1876
+ *
1877
+ * The instrument is COPIED, never shared (D39): the order keeps its snapshot of what was
1878
+ * intended, the payment gets its own record of what ran, and neither can rewrite the other.
1879
+ */
1880
+ /**
1881
+ * Refuse the confirm when the initial tender needs a reference and none reached us.
1882
+ *
1883
+ * "None reached us" means no typed `InitialPaymentReference`, no `InitialPaymentDetailID`,
1884
+ * or a detail whose `ReferenceNumber` is blank — an instrument row with an empty reference
1885
+ * is the same failure wearing a foreign key.
1886
+ */
1887
+ async requireReferenceWhenTenderDemandsOne(provider, user) {
1888
+ const type = await provider.GetEntityObject(PAYMENT_TYPE_ENTITY, user);
1889
+ const loaded = await type.Load(this.InitialPaymentTypeID);
1890
+ if (!loaded || !type.RequiresReference)
1891
+ return;
1892
+ let reference = (this.InitialPaymentReference ?? '').trim() || null;
1893
+ if (!reference && this.InitialPaymentDetailID) {
1894
+ const detail = await provider.GetEntityObject(PAYMENT_DETAIL_ENTITY, user);
1895
+ if (await detail.Load(this.InitialPaymentDetailID)) {
1896
+ reference = (detail.ReferenceNumber ?? '').trim() || null;
1897
+ }
1898
+ }
1899
+ if (!reference) {
1900
+ throw new Error(`${type.Name} payments need a reference number — a check number, wire ` +
1901
+ `confirmation or transfer id. Without one the payment cannot be reconciled ` +
1902
+ `against the bank statement. Enter it on the order, or invoice on terms instead.`);
1903
+ }
1904
+ }
1905
+ async createInitialPayment(options) {
1906
+ const amount = this.InitialPaymentAmount ?? 0;
1907
+ if (!this.InitialPaymentTypeID || amount <= 0)
1908
+ return;
1909
+ const provider = this.ProviderToUse;
1910
+ const user = this.ContextCurrentUser;
1911
+ // A TENDER THAT REQUIRES A REFERENCE MUST HAVE ONE. Check, Wire and Internal Transfer carry
1912
+ // `RequiresReference` because a captured payment with no check number or confirmation id
1913
+ // cannot be reconciled against a bank statement — the money is recorded and unfindable.
1914
+ //
1915
+ // Enforced HERE, in the save path, rather than in the order screen. The screen should ask
1916
+ // for it too, but a rule that lives only in the screen is a rule that holds until the next
1917
+ // caller — a fixture, an import, the other entry lane — and this codebase has now been
1918
+ // caught by that three times.
1919
+ await this.requireReferenceWhenTenderDemandsOne(provider, user);
1920
+ // The payment owns its own instrument row (D39). If the order already has an
1921
+ // InitialPaymentDetail row (from the embedded record), copy it; otherwise mint a
1922
+ // fresh PaymentDetail from the typed reference.
1923
+ let paymentDetailID = null;
1924
+ if (this.InitialPaymentDetailID) {
1925
+ paymentDetailID = await this.copyPaymentDetail(this.InitialPaymentDetailID, options);
1926
+ }
1927
+ else if (this.InitialPaymentReference) {
1928
+ paymentDetailID = await this.createPaymentDetailFromReference(options);
1929
+ }
1930
+ // Typed as the SERVER subclass, which is what the class factory returns for this key: the
1931
+ // header's `Lines` collection lives there, and asking for the generated class would mean
1932
+ // casting it back to reach the very property this code exists to set.
1933
+ const payment = await provider.GetEntityObject(PAYMENT_HEADER_ENTITY, user);
1934
+ payment.NewRecord();
1935
+ payment.PaymentNumber = await this.assignPaymentNumber();
1936
+ payment.ReceivingCompanyID = this.CompanyID;
1937
+ payment.BillToOrganizationID = this.BillToOrganizationID;
1938
+ payment.BillToPersonID = this.BillToPersonID;
1939
+ payment.PaymentDate = this.OrderDate ?? new Date();
1940
+ payment.PaymentTypeID = this.InitialPaymentTypeID;
1941
+ payment.Amount = amount;
1942
+ payment.PaymentDetailID = paymentDetailID;
1943
+ payment.Status = 'Captured';
1944
+ payment.Description = `Initial payment for order ${this.OrderNumber}`;
1945
+ // The allocation rides the payment's Lines collection so both land in ONE save (D68). The
1946
+ // payment's Amount must equal the sum of its lines at capture, so writing the header first
1947
+ // and the allocation second would fail on a payment that is about to be exactly consistent.
1948
+ const line = await provider.GetEntityObject(PAYMENT_LINE_ENTITY, user);
1949
+ line.NewRecord();
1950
+ line.OrderHeaderID = this.ID;
1951
+ line.Amount = amount;
1952
+ line.AllocatedAt = new Date();
1953
+ line.AllocatedByUserID = user?.ID ?? null;
1954
+ // Attached, not assigned — the collection stamps PaymentHeaderID.
1955
+ payment.Lines.Add(line);
1956
+ if (!(await payment.Save(options))) {
1957
+ throw new Error(`Failed to create the initial payment for order ${this.OrderNumber}: ` +
1958
+ `${payment.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1959
+ }
1960
+ }
1961
+ /** Mint a PaymentDetail from the typed check / wire / transfer number. */
1962
+ async createPaymentDetailFromReference(options) {
1963
+ const provider = this.ProviderToUse;
1964
+ const detail = await provider.GetEntityObject(PAYMENT_DETAIL_ENTITY, this.ContextCurrentUser);
1965
+ detail.NewRecord();
1966
+ detail.CompanyID = this.CompanyID;
1967
+ detail.PaymentTypeID = this.InitialPaymentTypeID;
1968
+ detail.ReferenceNumber = this.InitialPaymentReference;
1969
+ if (!(await detail.Save(options))) {
1970
+ throw new Error(`Failed to record the payment reference for order ${this.OrderNumber}: ` +
1971
+ `${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1972
+ }
1973
+ return detail.ID;
1974
+ }
1975
+ /** Duplicate a PaymentDetail so each host owns its own immutable snapshot (D39). */
1976
+ async copyPaymentDetail(sourceID, options) {
1977
+ const provider = this.ProviderToUse;
1978
+ const source = await provider.GetEntityObject(PAYMENT_DETAIL_ENTITY, CompositeKey.FromID(sourceID), this.ContextCurrentUser);
1979
+ const copy = await provider.GetEntityObject(PAYMENT_DETAIL_ENTITY, this.ContextCurrentUser);
1980
+ copy.NewRecord();
1981
+ // `CopyFrom` rather than a field loop: it skips primary keys by default, which is the only
1982
+ // exclusion this copy actually needs. The loop it replaced also skipped `__mj_*`, but those
1983
+ // are ReadOnly and absent from spCreate's parameter list, so they cannot reach the insert.
1984
+ // `SourceCustomerPaymentMethodID` comes across with everything else — the guard that used to
1985
+ // "record where the copy came from" only fired when the source value was FALSY and then
1986
+ // assigned that same falsy value, so it was dead code stating the opposite of its comment.
1987
+ copy.CopyFrom(source);
1988
+ if (!(await copy.Save(options))) {
1989
+ throw new Error(`Failed to copy the payment instrument for order ${this.OrderNumber}: ` +
1990
+ `${copy.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1991
+ }
1992
+ return copy.ID;
1993
+ }
1994
+ async assignPaymentNumber() {
1995
+ return `PAY-${String(await this.nextSequence('PaymentSequence')).padStart(6, '0')}`;
1996
+ }
1997
+ // ─── Helpers ───────────────────────────────────────────────────────────────
1998
+ /** Resolver + accounting engine come from the shared bridge — see AccountingBridge.ts. */
1999
+ async buildResolver(provider, user) {
2000
+ return BuildGLAccountResolver(provider, user);
2001
+ }
2002
+ entityIDFor(entityName) {
2003
+ return EntityIDFor(entityName);
2004
+ }
2005
+ async loadLinesForBooking() {
2006
+ const rv = new RunView(this.ProviderToUse);
2007
+ const result = await rv.RunView({
2008
+ EntityName: ORDER_LINE_ENTITY,
2009
+ ExtraFilter: `OrderHeaderID='${this.ID}'`,
2010
+ OrderBy: 'LineNumber',
2011
+ ResultType: 'entity_object',
2012
+ }, this.ContextCurrentUser);
2013
+ return result?.Results ?? [];
2014
+ }
2015
+ async countPersistedLines() {
2016
+ if (!this.IsSaved)
2017
+ return 0;
2018
+ const rv = new RunView(this.ProviderToUse);
2019
+ const result = await rv.RunView({
2020
+ EntityName: ORDER_LINE_ENTITY,
2021
+ ExtraFilter: `OrderHeaderID='${this.ID}'`,
2022
+ Fields: ['ID'],
2023
+ ResultType: 'simple',
2024
+ }, this.ContextCurrentUser);
2025
+ return result?.Results?.length ?? 0;
2026
+ }
2027
+ };
2028
+ OrderEntityServer = __decorate([
2029
+ RegisterClass(BaseEntity, ORDER_ENTITY)
2030
+ ], OrderEntityServer);
2031
+ export { OrderEntityServer };
2032
+ /**
2033
+ * Extract human-readable error messages from an entity, including its leaf/root chain,
2034
+ * LatestResult, ResultHistory, and synchronous validation.
2035
+ */
2036
+ function ExtractEntityErrorMessage(entity) {
2037
+ if (!entity)
2038
+ return 'unknown error (null entity)';
2039
+ const candidateEntities = [entity, entity.LeafEntity, entity.RootEntity].filter(Boolean);
2040
+ const messages = [];
2041
+ for (const ent of candidateEntities) {
2042
+ const latest = ent.LatestResult;
2043
+ if (latest) {
2044
+ if (latest.Message && latest.Message.trim().length > 0) {
2045
+ const trimmed = latest.Message.trim();
2046
+ if (!messages.includes(trimmed))
2047
+ messages.push(trimmed);
2048
+ }
2049
+ if (latest.Error) {
2050
+ const errStr = typeof latest.Error === 'string' ? latest.Error : latest.Error.message || JSON.stringify(latest.Error);
2051
+ if (errStr && !messages.includes(errStr))
2052
+ messages.push(errStr);
2053
+ }
2054
+ if (latest.Errors && latest.Errors.length > 0) {
2055
+ for (const err of latest.Errors) {
2056
+ const field = err.Source ?? err.FieldName ?? '';
2057
+ const msg = err.Message ?? err.message ?? JSON.stringify(err);
2058
+ const formatted = field ? `${field}: ${msg}` : msg;
2059
+ if (formatted && !messages.includes(formatted))
2060
+ messages.push(formatted);
2061
+ }
2062
+ }
2063
+ }
2064
+ for (const res of ent.ResultHistory ?? []) {
2065
+ if (!res.Success) {
2066
+ if (res.Message && res.Message.trim().length > 0 && !messages.includes(res.Message.trim())) {
2067
+ messages.push(res.Message.trim());
2068
+ }
2069
+ if (res.Errors && res.Errors.length > 0) {
2070
+ for (const err of res.Errors) {
2071
+ const field = err.Source ?? err.FieldName ?? '';
2072
+ const msg = err.Message ?? err.message ?? JSON.stringify(err);
2073
+ const formatted = field ? `${field}: ${msg}` : msg;
2074
+ if (formatted && !messages.includes(formatted))
2075
+ messages.push(formatted);
2076
+ }
2077
+ }
2078
+ }
2079
+ }
2080
+ }
2081
+ // Also check synchronous validation directly if no messages found
2082
+ if (messages.length === 0) {
2083
+ const val = entity.Validate();
2084
+ if (!val.Success && val.Errors.length > 0) {
2085
+ for (const err of val.Errors) {
2086
+ const field = err.Source ?? '';
2087
+ const msg = err.Message ?? JSON.stringify(err);
2088
+ const formatted = field ? `${field}: ${msg}` : msg;
2089
+ if (formatted && !messages.includes(formatted))
2090
+ messages.push(formatted);
2091
+ }
2092
+ }
2093
+ }
2094
+ return messages.length > 0 ? messages.join('; ') : (entity.LatestResult?.CompleteMessage ?? 'unknown error');
2095
+ }
2096
+ /** Tree-shaking anchor — call from the server bootstrap so @RegisterClass is retained. */
2097
+ export function LoadOrderEntityServer() {
2098
+ // intentionally empty
2099
+ }
2100
+ //# sourceMappingURL=OrderEntityServer.js.map