@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.
- package/LICENSE +183 -0
- package/README.md +141 -2
- package/dist/AccountingBridge.d.ts +65 -0
- package/dist/AccountingBridge.d.ts.map +1 -0
- package/dist/AccountingBridge.js +102 -0
- package/dist/AccountingBridge.js.map +1 -0
- package/dist/AdvanceOrderStateOperation.d.ts +80 -0
- package/dist/AdvanceOrderStateOperation.d.ts.map +1 -0
- package/dist/AdvanceOrderStateOperation.js +267 -0
- package/dist/AdvanceOrderStateOperation.js.map +1 -0
- package/dist/ApplyAccountCreditOperation.d.ts +97 -0
- package/dist/ApplyAccountCreditOperation.d.ts.map +1 -0
- package/dist/ApplyAccountCreditOperation.js +275 -0
- package/dist/ApplyAccountCreditOperation.js.map +1 -0
- package/dist/BaseDeliveryChannel.d.ts +106 -0
- package/dist/BaseDeliveryChannel.d.ts.map +1 -0
- package/dist/BaseDeliveryChannel.js +36 -0
- package/dist/BaseDeliveryChannel.js.map +1 -0
- package/dist/BasePaymentProvider.d.ts +239 -0
- package/dist/BasePaymentProvider.d.ts.map +1 -0
- package/dist/BasePaymentProvider.js +91 -0
- package/dist/BasePaymentProvider.js.map +1 -0
- package/dist/BundleBehavior.d.ts +139 -0
- package/dist/BundleBehavior.d.ts.map +1 -0
- package/dist/BundleBehavior.js +173 -0
- package/dist/BundleBehavior.js.map +1 -0
- package/dist/BundleEngine.d.ts +60 -0
- package/dist/BundleEngine.d.ts.map +1 -0
- package/dist/BundleEngine.js +197 -0
- package/dist/BundleEngine.js.map +1 -0
- package/dist/CancelSubscriptionOperation.d.ts +98 -0
- package/dist/CancelSubscriptionOperation.d.ts.map +1 -0
- package/dist/CancelSubscriptionOperation.js +317 -0
- package/dist/CancelSubscriptionOperation.js.map +1 -0
- package/dist/CapturePaymentOperation.d.ts +62 -0
- package/dist/CapturePaymentOperation.d.ts.map +1 -0
- package/dist/CapturePaymentOperation.js +463 -0
- package/dist/CapturePaymentOperation.js.map +1 -0
- package/dist/CheckEntitlementOperation.d.ts +27 -0
- package/dist/CheckEntitlementOperation.d.ts.map +1 -0
- package/dist/CheckEntitlementOperation.js +45 -0
- package/dist/CheckEntitlementOperation.js.map +1 -0
- package/dist/CheckoutSessionService.d.ts +258 -0
- package/dist/CheckoutSessionService.d.ts.map +1 -0
- package/dist/CheckoutSessionService.js +1557 -0
- package/dist/CheckoutSessionService.js.map +1 -0
- package/dist/DeliveryBehavior.d.ts +121 -0
- package/dist/DeliveryBehavior.d.ts.map +1 -0
- package/dist/DeliveryBehavior.js +145 -0
- package/dist/DeliveryBehavior.js.map +1 -0
- package/dist/DeliveryRecipientResolver.d.ts +38 -0
- package/dist/DeliveryRecipientResolver.d.ts.map +1 -0
- package/dist/DeliveryRecipientResolver.js +93 -0
- package/dist/DeliveryRecipientResolver.js.map +1 -0
- package/dist/DeliveryResolver.d.ts +14 -0
- package/dist/DeliveryResolver.d.ts.map +1 -0
- package/dist/DeliveryResolver.js +45 -0
- package/dist/DeliveryResolver.js.map +1 -0
- package/dist/EmailDeliveryChannel.d.ts +31 -0
- package/dist/EmailDeliveryChannel.d.ts.map +1 -0
- package/dist/EmailDeliveryChannel.js +158 -0
- package/dist/EmailDeliveryChannel.js.map +1 -0
- package/dist/EntitlementBehavior.d.ts +235 -0
- package/dist/EntitlementBehavior.d.ts.map +1 -0
- package/dist/EntitlementBehavior.js +330 -0
- package/dist/EntitlementBehavior.js.map +1 -0
- package/dist/EntitlementEngine.d.ts +97 -0
- package/dist/EntitlementEngine.d.ts.map +1 -0
- package/dist/EntitlementEngine.js +338 -0
- package/dist/EntitlementEngine.js.map +1 -0
- package/dist/EntitlementGrantClaimDriver.d.ts +42 -0
- package/dist/EntitlementGrantClaimDriver.d.ts.map +1 -0
- package/dist/EntitlementGrantClaimDriver.js +158 -0
- package/dist/EntitlementGrantClaimDriver.js.map +1 -0
- package/dist/EntitlementRead.d.ts +82 -0
- package/dist/EntitlementRead.d.ts.map +1 -0
- package/dist/EntitlementRead.js +368 -0
- package/dist/EntitlementRead.js.map +1 -0
- package/dist/FulfillOrderLinesOperation.d.ts +34 -0
- package/dist/FulfillOrderLinesOperation.d.ts.map +1 -0
- package/dist/FulfillOrderLinesOperation.js +208 -0
- package/dist/FulfillOrderLinesOperation.js.map +1 -0
- package/dist/FulfillmentBehavior.d.ts +101 -0
- package/dist/FulfillmentBehavior.d.ts.map +1 -0
- package/dist/FulfillmentBehavior.js +145 -0
- package/dist/FulfillmentBehavior.js.map +1 -0
- package/dist/GLAccountResolver.d.ts +123 -0
- package/dist/GLAccountResolver.d.ts.map +1 -0
- package/dist/GLAccountResolver.js +175 -0
- package/dist/GLAccountResolver.js.map +1 -0
- package/dist/GetFulfillmentQueueOperation.d.ts +35 -0
- package/dist/GetFulfillmentQueueOperation.d.ts.map +1 -0
- package/dist/GetFulfillmentQueueOperation.js +221 -0
- package/dist/GetFulfillmentQueueOperation.js.map +1 -0
- package/dist/GetOverdueWorklistOperation.d.ts +41 -0
- package/dist/GetOverdueWorklistOperation.d.ts.map +1 -0
- package/dist/GetOverdueWorklistOperation.js +210 -0
- package/dist/GetOverdueWorklistOperation.js.map +1 -0
- package/dist/GiftCardBehavior.d.ts +97 -0
- package/dist/GiftCardBehavior.d.ts.map +1 -0
- package/dist/GiftCardBehavior.js +121 -0
- package/dist/GiftCardBehavior.js.map +1 -0
- package/dist/GiftCardEngine.d.ts +59 -0
- package/dist/GiftCardEngine.d.ts.map +1 -0
- package/dist/GiftCardEngine.js +197 -0
- package/dist/GiftCardEngine.js.map +1 -0
- package/dist/GuestOrderClaimDriver.d.ts +36 -0
- package/dist/GuestOrderClaimDriver.d.ts.map +1 -0
- package/dist/GuestOrderClaimDriver.js +161 -0
- package/dist/GuestOrderClaimDriver.js.map +1 -0
- package/dist/InvoiceBehavior.d.ts +394 -0
- package/dist/InvoiceBehavior.d.ts.map +1 -0
- package/dist/InvoiceBehavior.js +496 -0
- package/dist/InvoiceBehavior.js.map +1 -0
- package/dist/InvoiceBuilder.d.ts +48 -0
- package/dist/InvoiceBuilder.d.ts.map +1 -0
- package/dist/InvoiceBuilder.js +352 -0
- package/dist/InvoiceBuilder.js.map +1 -0
- package/dist/InvoiceDisplay.d.ts +96 -0
- package/dist/InvoiceDisplay.d.ts.map +1 -0
- package/dist/InvoiceDisplay.js +121 -0
- package/dist/InvoiceDisplay.js.map +1 -0
- package/dist/ListEntitlementsOperation.d.ts +21 -0
- package/dist/ListEntitlementsOperation.d.ts.map +1 -0
- package/dist/ListEntitlementsOperation.js +39 -0
- package/dist/ListEntitlementsOperation.js.map +1 -0
- package/dist/ManualPaymentProvider.d.ts +19 -0
- package/dist/ManualPaymentProvider.d.ts.map +1 -0
- package/dist/ManualPaymentProvider.js +92 -0
- package/dist/ManualPaymentProvider.js.map +1 -0
- package/dist/OrderEntityServer.d.ts +542 -0
- package/dist/OrderEntityServer.d.ts.map +1 -0
- package/dist/OrderEntityServer.js +2100 -0
- package/dist/OrderEntityServer.js.map +1 -0
- package/dist/OrderJournalEntryFactory.d.ts +140 -0
- package/dist/OrderJournalEntryFactory.d.ts.map +1 -0
- package/dist/OrderJournalEntryFactory.js +466 -0
- package/dist/OrderJournalEntryFactory.js.map +1 -0
- package/dist/OrderLineEntityServer.d.ts +90 -0
- package/dist/OrderLineEntityServer.d.ts.map +1 -0
- package/dist/OrderLineEntityServer.js +260 -0
- package/dist/OrderLineEntityServer.js.map +1 -0
- package/dist/OrdersSettings.d.ts +36 -0
- package/dist/OrdersSettings.d.ts.map +1 -0
- package/dist/OrdersSettings.js +147 -0
- package/dist/OrdersSettings.js.map +1 -0
- package/dist/PaymentAllocationFactory.d.ts +128 -0
- package/dist/PaymentAllocationFactory.d.ts.map +1 -0
- package/dist/PaymentAllocationFactory.js +235 -0
- package/dist/PaymentAllocationFactory.js.map +1 -0
- package/dist/PaymentHeaderEntityServer.d.ts +180 -0
- package/dist/PaymentHeaderEntityServer.d.ts.map +1 -0
- package/dist/PaymentHeaderEntityServer.js +656 -0
- package/dist/PaymentHeaderEntityServer.js.map +1 -0
- package/dist/PaymentIntentService.d.ts +101 -0
- package/dist/PaymentIntentService.d.ts.map +1 -0
- package/dist/PaymentIntentService.js +150 -0
- package/dist/PaymentIntentService.js.map +1 -0
- package/dist/PaymentJournalEntryFactory.d.ts +99 -0
- package/dist/PaymentJournalEntryFactory.d.ts.map +1 -0
- package/dist/PaymentJournalEntryFactory.js +127 -0
- package/dist/PaymentJournalEntryFactory.js.map +1 -0
- package/dist/PaymentLineEntityServer.d.ts +73 -0
- package/dist/PaymentLineEntityServer.d.ts.map +1 -0
- package/dist/PaymentLineEntityServer.js +303 -0
- package/dist/PaymentLineEntityServer.js.map +1 -0
- package/dist/PaymentProviderBehavior.d.ts +222 -0
- package/dist/PaymentProviderBehavior.d.ts.map +1 -0
- package/dist/PaymentProviderBehavior.js +368 -0
- package/dist/PaymentProviderBehavior.js.map +1 -0
- package/dist/PaymentProviderResolver.d.ts +84 -0
- package/dist/PaymentProviderResolver.d.ts.map +1 -0
- package/dist/PaymentProviderResolver.js +220 -0
- package/dist/PaymentProviderResolver.js.map +1 -0
- package/dist/PaymentReversalFactory.d.ts +107 -0
- package/dist/PaymentReversalFactory.d.ts.map +1 -0
- package/dist/PaymentReversalFactory.js +174 -0
- package/dist/PaymentReversalFactory.js.map +1 -0
- package/dist/PaymentSettlement.d.ts +56 -0
- package/dist/PaymentSettlement.d.ts.map +1 -0
- package/dist/PaymentSettlement.js +243 -0
- package/dist/PaymentSettlement.js.map +1 -0
- package/dist/PaymentTermsBehavior.d.ts +109 -0
- package/dist/PaymentTermsBehavior.d.ts.map +1 -0
- package/dist/PaymentTermsBehavior.js +172 -0
- package/dist/PaymentTermsBehavior.js.map +1 -0
- package/dist/PaymentWebhookHandler.d.ts +103 -0
- package/dist/PaymentWebhookHandler.d.ts.map +1 -0
- package/dist/PaymentWebhookHandler.js +246 -0
- package/dist/PaymentWebhookHandler.js.map +1 -0
- package/dist/PreviewPriceOperation.d.ts +62 -0
- package/dist/PreviewPriceOperation.d.ts.map +1 -0
- package/dist/PreviewPriceOperation.js +161 -0
- package/dist/PreviewPriceOperation.js.map +1 -0
- package/dist/PriceOrderOperation.d.ts +93 -0
- package/dist/PriceOrderOperation.d.ts.map +1 -0
- package/dist/PriceOrderOperation.js +146 -0
- package/dist/PriceOrderOperation.js.map +1 -0
- package/dist/ProductPriceEntityServer.d.ts +34 -0
- package/dist/ProductPriceEntityServer.d.ts.map +1 -0
- package/dist/ProductPriceEntityServer.js +97 -0
- package/dist/ProductPriceEntityServer.js.map +1 -0
- package/dist/RefundPaymentOperation.d.ts +61 -0
- package/dist/RefundPaymentOperation.d.ts.map +1 -0
- package/dist/RefundPaymentOperation.js +177 -0
- package/dist/RefundPaymentOperation.js.map +1 -0
- package/dist/RevenueRecognition.d.ts +76 -0
- package/dist/RevenueRecognition.d.ts.map +1 -0
- package/dist/RevenueRecognition.js +133 -0
- package/dist/RevenueRecognition.js.map +1 -0
- package/dist/ReversalBehavior.d.ts +82 -0
- package/dist/ReversalBehavior.d.ts.map +1 -0
- package/dist/ReversalBehavior.js +100 -0
- package/dist/ReversalBehavior.js.map +1 -0
- package/dist/ReversalResolver.d.ts +37 -0
- package/dist/ReversalResolver.d.ts.map +1 -0
- package/dist/ReversalResolver.js +96 -0
- package/dist/ReversalResolver.js.map +1 -0
- package/dist/SpawnRenewalsOperation.d.ts +109 -0
- package/dist/SpawnRenewalsOperation.d.ts.map +1 -0
- package/dist/SpawnRenewalsOperation.js +295 -0
- package/dist/SpawnRenewalsOperation.js.map +1 -0
- package/dist/StoredValuePaymentProvider.d.ts +52 -0
- package/dist/StoredValuePaymentProvider.d.ts.map +1 -0
- package/dist/StoredValuePaymentProvider.js +205 -0
- package/dist/StoredValuePaymentProvider.js.map +1 -0
- package/dist/StripeACHPaymentProvider.d.ts +50 -0
- package/dist/StripeACHPaymentProvider.d.ts.map +1 -0
- package/dist/StripeACHPaymentProvider.js +211 -0
- package/dist/StripeACHPaymentProvider.js.map +1 -0
- package/dist/StripePaymentProvider.d.ts +83 -0
- package/dist/StripePaymentProvider.d.ts.map +1 -0
- package/dist/StripePaymentProvider.js +442 -0
- package/dist/StripePaymentProvider.js.map +1 -0
- package/dist/SubscriptionBehavior.d.ts +197 -0
- package/dist/SubscriptionBehavior.d.ts.map +1 -0
- package/dist/SubscriptionBehavior.js +415 -0
- package/dist/SubscriptionBehavior.js.map +1 -0
- package/dist/checkoutCaptureAlert.d.ts +16 -0
- package/dist/checkoutCaptureAlert.d.ts.map +1 -0
- package/dist/checkoutCaptureAlert.js +55 -0
- package/dist/checkoutCaptureAlert.js.map +1 -0
- package/dist/checkoutCaptureRetry.d.ts +27 -0
- package/dist/checkoutCaptureRetry.d.ts.map +1 -0
- package/dist/checkoutCaptureRetry.js +50 -0
- package/dist/checkoutCaptureRetry.js.map +1 -0
- package/dist/claimDriverHelpers.d.ts +15 -0
- package/dist/claimDriverHelpers.d.ts.map +1 -0
- package/dist/claimDriverHelpers.js +34 -0
- package/dist/claimDriverHelpers.js.map +1 -0
- package/dist/entity-names.d.ts +15 -0
- package/dist/entity-names.d.ts.map +1 -0
- package/dist/entity-names.js +15 -0
- package/dist/entity-names.js.map +1 -0
- package/dist/identityClaimContracts.d.ts +118 -0
- package/dist/identityClaimContracts.d.ts.map +1 -0
- package/dist/identityClaimContracts.js +58 -0
- package/dist/identityClaimContracts.js.map +1 -0
- package/dist/index.d.ts +126 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +115 -0
- package/dist/index.js.map +1 -0
- package/dist/sql-guards.d.ts +78 -0
- package/dist/sql-guards.d.ts.map +1 -0
- package/dist/sql-guards.js +115 -0
- package/dist/sql-guards.js.map +1 -0
- 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
|