@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,656 @@
|
|
|
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
|
+
* PaymentHeader server subclass — books the PROCESSING FEE when a payment is captured (plan D18).
|
|
9
|
+
*
|
|
10
|
+
* WHAT THIS BOOKS, AND WHY IT IS ONLY THE FEE
|
|
11
|
+
* Capture used to book the whole cash leg here: `Dr Cash / Dr Fee / Cr AR` against the receiving
|
|
12
|
+
* company. That was wrong as soon as an order carried another company's product — it credited the
|
|
13
|
+
* collector's receivable for money the collector was never owed, and left the owner's receivable
|
|
14
|
+
* open forever. Balanced, posted, and invisible (D13; intercompany-balancing.md §1).
|
|
15
|
+
*
|
|
16
|
+
* The cash and receivable sides moved to `PaymentLineEntityServer`, because ALLOCATION is the
|
|
17
|
+
* earliest point at which the owning companies are known at all. A capture says how much cash
|
|
18
|
+
* arrived; only an allocation says whose revenue it settles.
|
|
19
|
+
*
|
|
20
|
+
* The fee stays here because it genuinely is a header fact: the processor takes its cut from the
|
|
21
|
+
* payment as a whole, not from any one order, and pro-rating it across allocations would invent
|
|
22
|
+
* precision the underlying fact does not have.
|
|
23
|
+
*
|
|
24
|
+
* WHEN IT BOOKS
|
|
25
|
+
* On the transition INTO `Captured`, once, and only when there IS a fee with an account to book it
|
|
26
|
+
* to. `JournalEntryID` is NULL→value-once and is checked first, so a re-save never books twice.
|
|
27
|
+
*
|
|
28
|
+
* ── THE ALLOCATION INVARIANT (D68) ─────────────────────────────────────────────────────────────
|
|
29
|
+
* `Amount` MUST equal the sum of the payment's lines. A payment that says 1000 arrived while its
|
|
30
|
+
* allocations total 600 is internally inconsistent, and the missing 400 has no home in the ledger —
|
|
31
|
+
* which is how "unapplied cash" became a concept that needed inventing. Requiring the two to agree
|
|
32
|
+
* removes it: every dollar lands on an order, and allocating MORE than an order is worth simply
|
|
33
|
+
* drives that order's balance negative, which is a customer credit and spendable as tender.
|
|
34
|
+
*
|
|
35
|
+
* The invariant is enforced at the CAPTURE TRANSITION, not on every save. A `Pending` payment is a
|
|
36
|
+
* draft — half-entered, correctable, booking nothing — exactly as a `Draft` order may sit with no
|
|
37
|
+
* lines. Locking on save rather than on status would make an ordinary typo permanent.
|
|
38
|
+
*
|
|
39
|
+
* Lines are held in memory on `Lines` and saved with the header inside ONE transaction (the shape
|
|
40
|
+
* `OrderEntityServer` uses), so there is never a persisted moment where the header and its lines
|
|
41
|
+
* disagree. After capture both are frozen — the header by trigger 51005, the lines by 51010/51011.
|
|
42
|
+
*
|
|
43
|
+
* ATOMICITY
|
|
44
|
+
* The entry is written inside the caller's transaction, exactly as order booking is. A payment that
|
|
45
|
+
* fails to book does not persist as captured.
|
|
46
|
+
*
|
|
47
|
+
* REFUNDS reverse. A payment saved as `Refunded` books the mirror of its capture (D53): same
|
|
48
|
+
* accounts, debit and credit swapped, positive amounts.
|
|
49
|
+
*
|
|
50
|
+
* CONNECTS TO:
|
|
51
|
+
* FACTORY: PaymentJournalEntryFactory (./PaymentJournalEntryFactory.ts)
|
|
52
|
+
* ALLOCATION: PaymentLineEntityServer (./PaymentLineEntityServer.ts) — the cash/AR side
|
|
53
|
+
* RESOLVER: GLAccountResolver (./GLAccountResolver.ts)
|
|
54
|
+
* OP: 'Accounting.CreateJournalEntries' — the same op OrderEntityServer uses
|
|
55
|
+
* TABLE: __mj_BizAppsOrders.PaymentHeader
|
|
56
|
+
*/
|
|
57
|
+
import { BaseEntity, BaseEntityResult, BaseRemotableOperation, EntitySaveOptions, LogError, LogStatus, RunView, ValidationErrorInfo, ValidationErrorType, } from '@memberjunction/core';
|
|
58
|
+
import { MJGlobal, RegisterClass } from '@memberjunction/global';
|
|
59
|
+
import { PaymentHeaderEntity } from '@mj-biz-apps/orders-entities';
|
|
60
|
+
import { AccountingEngineBase } from '@mj-biz-apps/accounting-engine-base';
|
|
61
|
+
import { BuildGLAccountResolver, EntityIDFor } from './AccountingBridge.js';
|
|
62
|
+
import { ResolvePaymentProvider } from './PaymentProviderResolver.js';
|
|
63
|
+
import { ShouldHoldForLateSettlement, SplitCapturedAmount } from './PaymentProviderBehavior.js';
|
|
64
|
+
import { PaymentJournalEntryFactory } from './PaymentJournalEntryFactory.js';
|
|
65
|
+
import { PaymentAllocationFactory, } from './PaymentAllocationFactory.js';
|
|
66
|
+
import { LoadOrdersEngine, OrdersEngine } from '@mj-biz-apps/orders-entities';
|
|
67
|
+
import { ORDER_HEADER_ENTITY, ORDER_LINE_ENTITY } from './entity-names.js';
|
|
68
|
+
const PAYMENT_HEADER_ENTITY = 'MJ_BizApps_Orders: Payment Headers';
|
|
69
|
+
const PAYMENT_LINE_ENTITY = 'MJ_BizApps_Orders: Payment Lines';
|
|
70
|
+
/** Statuses whose entry belongs in the ledger. */
|
|
71
|
+
const BOOKED_STATUSES = new Set(['Captured', 'Refunded']);
|
|
72
|
+
let PaymentHeaderEntityServer = class PaymentHeaderEntityServer extends PaymentHeaderEntity {
|
|
73
|
+
// `Lines` is a RelatedRecordCollection on the GENERATED class now, emitted from the
|
|
74
|
+
// RelatedRecordCollection metadata on the 'Payment Headers -> Payment Lines' relationship, so it
|
|
75
|
+
// exists on both tiers. It replaces a `_lines` array plus getter/setter that lived only on the
|
|
76
|
+
// server. No Sequence policy is declared: an allocation is identified by which order line it
|
|
77
|
+
// pays, not by position, and PaymentLine has no line-number column to renumber.
|
|
78
|
+
Validate() {
|
|
79
|
+
const result = super.Validate();
|
|
80
|
+
// A fee larger than the payment would put a negative amount into Cash.
|
|
81
|
+
const fee = this.ProcessingFeeAmount ?? 0;
|
|
82
|
+
if (fee < 0 || fee > Math.abs(this.Amount ?? 0)) {
|
|
83
|
+
result.Success = false;
|
|
84
|
+
result.Errors.push(new ValidationErrorInfo('ProcessingFeeAmount', `The processing fee (${fee}) must be between 0 and the payment amount ` +
|
|
85
|
+
`(${this.Amount}). A larger fee would book negative cash.`, fee, ValidationErrorType.Failure));
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
async Save(options) {
|
|
90
|
+
if (!this.PaymentNumber) {
|
|
91
|
+
this.PaymentNumber = await this.assignPaymentNumber();
|
|
92
|
+
}
|
|
93
|
+
// A rail that settles on somebody else's schedule may not reach Captured here, whoever asked.
|
|
94
|
+
await this.deferCaptureWhenSettlementIsLate();
|
|
95
|
+
const capturing = this.willBookOnThisSave();
|
|
96
|
+
// A Pending payment with no new lines is an ordinary row save — nothing to co-ordinate.
|
|
97
|
+
if (!capturing && !this.Lines.Dirty) {
|
|
98
|
+
return super.Save(options);
|
|
99
|
+
}
|
|
100
|
+
const dbProvider = this.ProviderToUse;
|
|
101
|
+
await dbProvider.BeginTransaction();
|
|
102
|
+
try {
|
|
103
|
+
// SETTLE WITH THE GATEWAY BEFORE THE ROW IS WRITTEN (D19).
|
|
104
|
+
//
|
|
105
|
+
// Two reasons, and the second is the one that bit. If the ledger were written first and the
|
|
106
|
+
// gateway then declined, the rollback would undo our entries — but a gateway that CAPTURED
|
|
107
|
+
// and then failed to answer has taken the customer's money while we recorded nothing.
|
|
108
|
+
//
|
|
109
|
+
// And the gateway is the authority on the AMOUNT and the FEE: a partial capture, or a fee
|
|
110
|
+
// different from the one assumed, is what actually moved. Those land on THIS object, so
|
|
111
|
+
// they have to be set before `super.Save()` persists it. Calling this afterwards computed
|
|
112
|
+
// the right fee and threw it away — the row kept its zero, and the only reason anyone
|
|
113
|
+
// noticed was that PV4 asserted the fee rather than just the status.
|
|
114
|
+
if (capturing)
|
|
115
|
+
await this.settleWithProvider();
|
|
116
|
+
// COMPLETE THE ALLOCATIONS BEFORE THE HEADER SAVE, because `Lines` is a companion and MJ
|
|
117
|
+
// validates companions from the PARENT's save — before any line's own Save() runs.
|
|
118
|
+
//
|
|
119
|
+
// `AllocatedAt` is NOT NULL and used to be defaulted during the line-save loop, which ran
|
|
120
|
+
// after the header. Most callers set it (CapturePaymentOperation, PaymentReversalFactory,
|
|
121
|
+
// OrderEntityServer) but the fallback exists precisely for those that do not, and without
|
|
122
|
+
// it here their allocations fail validation on a column no caller is required to author.
|
|
123
|
+
//
|
|
124
|
+
// The foreign key is NOT set here — the collection stamps PaymentHeaderID itself, which is
|
|
125
|
+
// what makes this correct even when the header is new and its key is minted by this save.
|
|
126
|
+
for (const line of this.Lines.Items) {
|
|
127
|
+
if (!line.AllocatedAt)
|
|
128
|
+
line.AllocatedAt = new Date();
|
|
129
|
+
}
|
|
130
|
+
// THE HEADER + EMBEDS, then the allocations explicitly.
|
|
131
|
+
//
|
|
132
|
+
// Letting the graph write the lines reads better and IS what the journal-entry path
|
|
133
|
+
// does — but it fails here, and the suite says so: `assertAllocationInvariant` and
|
|
134
|
+
// `bookAllocations` both read PaymentLine FROM THE DATABASE by PaymentHeaderID inside
|
|
135
|
+
// this transaction.
|
|
136
|
+
for (const line of this.Lines.Items) {
|
|
137
|
+
line.PaymentHeaderID = this.ID;
|
|
138
|
+
}
|
|
139
|
+
if (!(await super.Save({ ...options, SkipRelatedCollections: true }))) {
|
|
140
|
+
throw new Error(`Failed to save payment ${this.PaymentNumber}: ` +
|
|
141
|
+
`${this.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
142
|
+
}
|
|
143
|
+
// The lines go down BEFORE the invariant is checked, because the check reads what is
|
|
144
|
+
// actually persisted rather than what this object happens to be holding — a line that
|
|
145
|
+
// silently failed to save would otherwise still count toward the total.
|
|
146
|
+
for (const line of this.Lines.Items) {
|
|
147
|
+
const lineSaveOptions = new EntitySaveOptions();
|
|
148
|
+
if (options)
|
|
149
|
+
Object.assign(lineSaveOptions, options);
|
|
150
|
+
lineSaveOptions.IsParentEntitySave = true;
|
|
151
|
+
if (!(await line.Save(lineSaveOptions))) {
|
|
152
|
+
throw new Error(`Failed to save a payment allocation for ${this.PaymentNumber}: ` +
|
|
153
|
+
`${line.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (capturing) {
|
|
157
|
+
// Book journal entries for all unbooked allocations of this payment in one atomic batch.
|
|
158
|
+
await this.bookAllocations(options);
|
|
159
|
+
await this.assertAllocationInvariant();
|
|
160
|
+
// Only reach the fee builder when there IS a fee AND this tender books one inline.
|
|
161
|
+
// OFF BY DEFAULT (D82).
|
|
162
|
+
if (Number(this.ProcessingFeeAmount ?? 0) > 0 && (await this.feeBooksInline())) {
|
|
163
|
+
await this.bookProcessingFee(options);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
await dbProvider.CommitTransaction();
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
LogError(`PaymentHeaderEntityServer.Save failed for ${this.PaymentNumber ?? this.ID}: ${err}`);
|
|
171
|
+
try {
|
|
172
|
+
await dbProvider.RollbackTransaction();
|
|
173
|
+
}
|
|
174
|
+
catch (rollbackErr) {
|
|
175
|
+
LogError(`Rollback failed after payment save error: ${rollbackErr}`);
|
|
176
|
+
}
|
|
177
|
+
// Same reasoning as OrderEntityServer (D50): a bare `false` with LatestResult still
|
|
178
|
+
// holding the successful row save reads as "it just didn't work".
|
|
179
|
+
this.RegisterResultHistoryEntry(this.buildFailureResult(err));
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* True when this save needs to book journal entries.
|
|
185
|
+
* Evaluates true on a new record being saved as Captured/Refunded, on status transitions into
|
|
186
|
+
* Captured/Refunded, or when there are unbooked lines attached.
|
|
187
|
+
*/
|
|
188
|
+
willBookOnThisSave() {
|
|
189
|
+
if (!BOOKED_STATUSES.has(this.Status))
|
|
190
|
+
return false;
|
|
191
|
+
if (!this.IsSaved)
|
|
192
|
+
return true;
|
|
193
|
+
const previousStatus = this.GetFieldByName('Status')?.OldValue;
|
|
194
|
+
if (previousStatus !== this.Status)
|
|
195
|
+
return true;
|
|
196
|
+
if (this.Lines.Items.some((l) => !l.BookedAt))
|
|
197
|
+
return true;
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* A payment on an asynchronously-settling rail lands `Pending`, whatever the caller wrote.
|
|
202
|
+
*
|
|
203
|
+
* WHY THIS IS HERE AND NOT ONLY IN `Orders.CapturePayment`. The operation already asks the driver
|
|
204
|
+
* this question and sets the status accordingly, and that was the whole of the rule — which made
|
|
205
|
+
* it a rule the OPERATION follows rather than one the system enforces. Every other way of writing
|
|
206
|
+
* a payment went straight past it: a workflow saving a `PaymentHeader`, a UI form, a migration, a
|
|
207
|
+
* test builder that defaults `Status` to `'Captured'`. Each of those books `Dr Cash` for a bank
|
|
208
|
+
* debit that has not cleared, and the entry is perfectly balanced, so nothing downstream can tell.
|
|
209
|
+
* Finance sees cash four days before the bank does, and the only symptom is a reconciliation that
|
|
210
|
+
* is off by whatever ACH ran that week.
|
|
211
|
+
*
|
|
212
|
+
* This is the last shared point before booking, so it is the honest place for the invariant. The
|
|
213
|
+
* operation's copy stays — it needs the answer BEFORE the save so it can report the status it is
|
|
214
|
+
* about to produce — and the two now agree by construction rather than by both remembering.
|
|
215
|
+
*
|
|
216
|
+
* THE PROMOTION MUST STILL PASS. When the webhook moves a `Pending` payment to `Captured`, that IS
|
|
217
|
+
* the bank answering, and deferring it again would mean a bank debit could never book at all. The
|
|
218
|
+
* signal is the PERSISTED status: a row that was already `Pending` is being promoted; a new row,
|
|
219
|
+
* or one arriving from any other state, is a caller declaring cash that has not moved.
|
|
220
|
+
*
|
|
221
|
+
* SILENT ON PURPOSE, AND ONLY HERE. It changes the status rather than refusing the save, because
|
|
222
|
+
* `Pending` is what the caller should have written and refusing would strand a payment the gateway
|
|
223
|
+
* has already been told about. `Orders.CapturePayment` reports the resulting status back, so a
|
|
224
|
+
* caller going through the front door is told plainly what happened.
|
|
225
|
+
*/
|
|
226
|
+
async deferCaptureWhenSettlementIsLate() {
|
|
227
|
+
if (this.Status !== 'Captured')
|
|
228
|
+
return;
|
|
229
|
+
// The webhook promoting a Pending payment — the bank has answered, so this one books.
|
|
230
|
+
const previousStatus = this.GetFieldByName('Status')?.OldValue;
|
|
231
|
+
if (this.IsSaved && previousStatus === 'Pending')
|
|
232
|
+
return;
|
|
233
|
+
const providerID = this.PaymentProviderID;
|
|
234
|
+
// No gateway means a RECORDED payment — cheque, cash, wire. Nothing settles late.
|
|
235
|
+
if (!providerID)
|
|
236
|
+
return;
|
|
237
|
+
let settlesLate = false;
|
|
238
|
+
try {
|
|
239
|
+
const driver = await ResolvePaymentProvider(providerID, this.ProviderToUse, this.ContextCurrentUser);
|
|
240
|
+
settlesLate = driver.SettlesAsynchronously === true;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// A provider that will not resolve is a configuration fault, and `settleWithProvider` is
|
|
244
|
+
// about to report it far more precisely than a status guess here would. Leaving the
|
|
245
|
+
// status alone keeps every rail that worked before working unchanged.
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (!ShouldHoldForLateSettlement({
|
|
249
|
+
RequestedStatus: this.Status,
|
|
250
|
+
PersistedStatus: previousStatus,
|
|
251
|
+
IsSaved: this.IsSaved,
|
|
252
|
+
HasProvider: true,
|
|
253
|
+
SettlesAsynchronously: settlesLate,
|
|
254
|
+
})) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
LogStatus(`Payment ${this.PaymentNumber}: this provider settles asynchronously, so the payment is ` +
|
|
258
|
+
`held at Pending and its allocations persist unbooked. The webhook books the cash leg ` +
|
|
259
|
+
`when the bank confirms.`);
|
|
260
|
+
this.Status = 'Pending';
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Persist the in-memory allocation lines against this payment.
|
|
264
|
+
*
|
|
265
|
+
* `PaymentLineEntityServer` books each one's journal entries as it saves, and it reads the
|
|
266
|
+
* payment's status to decide whether to — so by the time these run the header is already
|
|
267
|
+
* `Captured` and the cash leg follows automatically.
|
|
268
|
+
*/
|
|
269
|
+
/**
|
|
270
|
+
* Book one journal entry per company owning a line on the orders settled by unbooked allocations.
|
|
271
|
+
*
|
|
272
|
+
* In the payment-header save, all allocations for the payment are gathered and drafted together,
|
|
273
|
+
* submitted to Accounting.CreateJournalEntries in one batch inside the payment transaction, and
|
|
274
|
+
* stamped with BookedAt.
|
|
275
|
+
*/
|
|
276
|
+
async bookAllocations(options) {
|
|
277
|
+
const provider = this.ProviderToUse;
|
|
278
|
+
const user = this.ContextCurrentUser;
|
|
279
|
+
const unbookedLines = [];
|
|
280
|
+
const seenLineIDs = new Set();
|
|
281
|
+
for (const line of this.Lines.Items) {
|
|
282
|
+
if (!line.BookedAt) {
|
|
283
|
+
unbookedLines.push(line);
|
|
284
|
+
if (line.ID)
|
|
285
|
+
seenLineIDs.add(String(line.ID).toLowerCase());
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (this.ID) {
|
|
289
|
+
const rv = new RunView(this.ProviderToUse);
|
|
290
|
+
const res = await rv.RunView({
|
|
291
|
+
EntityName: PAYMENT_LINE_ENTITY,
|
|
292
|
+
ExtraFilter: `PaymentHeaderID='${this.ID}' AND BookedAt IS NULL`,
|
|
293
|
+
ResultType: 'entity_object',
|
|
294
|
+
BypassCache: true,
|
|
295
|
+
}, this.ContextCurrentUser);
|
|
296
|
+
for (const line of (res?.Results ?? [])) {
|
|
297
|
+
if (!seenLineIDs.has(String(line.ID).toLowerCase())) {
|
|
298
|
+
unbookedLines.push(line);
|
|
299
|
+
seenLineIDs.add(String(line.ID).toLowerCase());
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (unbookedLines.length === 0)
|
|
304
|
+
return;
|
|
305
|
+
await AccountingEngineBase.Instance.Config(false, user, provider);
|
|
306
|
+
const resolver = await BuildGLAccountResolver(provider, user);
|
|
307
|
+
const factory = new PaymentAllocationFactory(resolver, (source, target, asOf) => {
|
|
308
|
+
const hit = AccountingEngineBase.Instance.ResolveIntercompanyAccounts(source, target, asOf);
|
|
309
|
+
return hit ? { DueToGLAccountID: hit.DueTo.GLAccountID, DueFromGLAccountID: hit.DueFrom.GLAccountID } : null;
|
|
310
|
+
}, EntityIDFor(PAYMENT_LINE_ENTITY));
|
|
311
|
+
const allDrafts = [];
|
|
312
|
+
for (const line of unbookedLines) {
|
|
313
|
+
const orderLines = await this.loadOrderLines(line.OrderHeaderID);
|
|
314
|
+
if (orderLines.length === 0) {
|
|
315
|
+
throw new Error(`Cannot allocate ${line.Amount} to order ${line.OrderHeaderID}: the order has no lines, so ` +
|
|
316
|
+
`there is no basis for deciding whose revenue the cash settles.`);
|
|
317
|
+
}
|
|
318
|
+
const orderNumber = await this.loadOrderNumber(line.OrderHeaderID);
|
|
319
|
+
const { Drafts } = await factory.BuildAllocationDrafts({
|
|
320
|
+
PaymentLineID: line.ID,
|
|
321
|
+
PaymentNumber: this.PaymentNumber,
|
|
322
|
+
OrderNumber: orderNumber,
|
|
323
|
+
Amount: line.Amount ?? 0,
|
|
324
|
+
ReceivingCompanyID: this.ReceivingCompanyID,
|
|
325
|
+
OrderLines: orderLines,
|
|
326
|
+
TargetOrderLineID: line.OrderLineID ?? null,
|
|
327
|
+
PaymentDate: this.PaymentDate ? new Date(this.PaymentDate) : new Date(),
|
|
328
|
+
IsReversal: this.Status === 'Refunded' || (line.Amount ?? 0) < 0,
|
|
329
|
+
});
|
|
330
|
+
allDrafts.push(...Drafts);
|
|
331
|
+
}
|
|
332
|
+
if (allDrafts.length === 0)
|
|
333
|
+
return;
|
|
334
|
+
const result = await this.createJournalEntries(allDrafts, provider, user);
|
|
335
|
+
if ((result.Results?.length ?? 0) !== allDrafts.length) {
|
|
336
|
+
throw new Error(`Accounting returned ${result.Results?.length ?? 0} journal entries for allocations that ` +
|
|
337
|
+
`produced ${allDrafts.length} drafts. The cash leg is incomplete; refusing to commit.`);
|
|
338
|
+
}
|
|
339
|
+
for (const line of unbookedLines) {
|
|
340
|
+
line.BookedAt = new Date();
|
|
341
|
+
const saveOptions = new EntitySaveOptions();
|
|
342
|
+
if (options)
|
|
343
|
+
Object.assign(saveOptions, options);
|
|
344
|
+
saveOptions.IsParentEntitySave = true;
|
|
345
|
+
if (!(await line.Save(saveOptions))) {
|
|
346
|
+
throw new Error(`Failed to stamp BookedAt on the allocation: ${line.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
async loadOrderLines(orderHeaderID) {
|
|
351
|
+
const rv = new RunView(this.ProviderToUse);
|
|
352
|
+
const res = await rv.RunView({
|
|
353
|
+
EntityName: ORDER_LINE_ENTITY,
|
|
354
|
+
ExtraFilter: `OrderHeaderID='${orderHeaderID}'`,
|
|
355
|
+
Fields: ['ID', 'CompanyID', 'LineTotalGross'],
|
|
356
|
+
ResultType: 'simple',
|
|
357
|
+
BypassCache: true,
|
|
358
|
+
}, this.ContextCurrentUser);
|
|
359
|
+
if (!res?.Success) {
|
|
360
|
+
throw new Error(`Could not read the order's lines to allocate the payment: ${res?.ErrorMessage ?? 'unknown error'}`);
|
|
361
|
+
}
|
|
362
|
+
return (res.Results ?? []).map((l) => ({
|
|
363
|
+
OrderLineID: l.ID,
|
|
364
|
+
CompanyID: l.CompanyID,
|
|
365
|
+
Amount: Number(l.LineTotalGross ?? 0),
|
|
366
|
+
}));
|
|
367
|
+
}
|
|
368
|
+
async loadOrderNumber(orderHeaderID) {
|
|
369
|
+
const rv = new RunView(this.ProviderToUse);
|
|
370
|
+
const res = await rv.RunView({
|
|
371
|
+
EntityName: ORDER_HEADER_ENTITY,
|
|
372
|
+
ExtraFilter: `ID='${orderHeaderID}'`,
|
|
373
|
+
Fields: ['OrderNumber'],
|
|
374
|
+
ResultType: 'simple',
|
|
375
|
+
BypassCache: true,
|
|
376
|
+
}, this.ContextCurrentUser);
|
|
377
|
+
return res?.Results?.[0]?.OrderNumber ?? String(orderHeaderID);
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* `Amount` must equal the sum of the payment's persisted lines (D68).
|
|
381
|
+
*
|
|
382
|
+
* Checked at CAPTURE only. A `Pending` payment is a draft and may be as inconsistent as a
|
|
383
|
+
* half-typed form; capture is the moment it becomes a claim about money that actually moved,
|
|
384
|
+
* and from then on the header and lines are both frozen.
|
|
385
|
+
*
|
|
386
|
+
* Read from the database rather than from `this._lines`: that is the number the ledger and
|
|
387
|
+
* every downstream report will see.
|
|
388
|
+
*/
|
|
389
|
+
async assertAllocationInvariant() {
|
|
390
|
+
const rv = new RunView(this.ProviderToUse);
|
|
391
|
+
const res = await rv.RunView({
|
|
392
|
+
EntityName: PAYMENT_LINE_ENTITY,
|
|
393
|
+
ExtraFilter: `PaymentHeaderID='${this.ID}'`,
|
|
394
|
+
Fields: ['Amount'],
|
|
395
|
+
ResultType: 'simple',
|
|
396
|
+
BypassCache: true,
|
|
397
|
+
}, this.ContextCurrentUser);
|
|
398
|
+
if (!res?.Success) {
|
|
399
|
+
throw new Error(`Could not read the allocations for payment ${this.PaymentNumber}: ${res?.ErrorMessage ?? 'unknown error'}`);
|
|
400
|
+
}
|
|
401
|
+
const rows = res.Results ?? [];
|
|
402
|
+
const allocated = Math.round(rows.reduce((sum, l) => sum + Number(l.Amount ?? 0), 0) * 100) / 100;
|
|
403
|
+
const amount = Math.round(Number(this.Amount ?? 0) * 100) / 100;
|
|
404
|
+
// DIRECTION. `Amount` is stored as a positive magnitude on both a capture and a refund —
|
|
405
|
+
// "how much moved" — while the LINES are signed by which way it moved: positive applies cash
|
|
406
|
+
// to an order, negative takes it back off. So a refund of 250 is Amount 250 with lines
|
|
407
|
+
// totalling -250, and comparing the two naively would fail every refund ever written.
|
|
408
|
+
// `Status === 'Refunded'` is the same discriminator the booking path already uses to decide
|
|
409
|
+
// whether to mirror the entry (D53), so the two stay in step by construction.
|
|
410
|
+
const reversal = this.Status === 'Refunded';
|
|
411
|
+
const expected = reversal ? -amount : amount;
|
|
412
|
+
if (allocated === expected)
|
|
413
|
+
return;
|
|
414
|
+
const shortfall = Math.round((expected - allocated) * 100) / 100;
|
|
415
|
+
const verb = reversal ? 'refunded' : 'captured';
|
|
416
|
+
throw new Error(`Payment ${this.PaymentNumber} cannot be ${verb}: it is for ${amount} but its ` +
|
|
417
|
+
`${rows.length} allocation${rows.length === 1 ? '' : 's'} total ${allocated} ` +
|
|
418
|
+
`(expected ${expected}), leaving ${shortfall} unaccounted for. Every part of a payment must ` +
|
|
419
|
+
`land on an order — allocating MORE than an order is worth is allowed and simply leaves that ` +
|
|
420
|
+
`order with a credit balance, which can be spent on another order later. Adjust the ` +
|
|
421
|
+
`allocations, or record the payment for what actually moved.`);
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Ask the gateway to actually take the money, and record what it says happened.
|
|
425
|
+
*
|
|
426
|
+
* NO PROVIDER, NO CALL. `PaymentProviderID` is nullable, and a payment without one is a payment
|
|
427
|
+
* nobody is asking a gateway about — a back-office correction, an account-credit transfer (D68), a
|
|
428
|
+
* historical import. Those capture exactly as they did before this method existed, which is why it
|
|
429
|
+
* returns early rather than refusing: requiring a provider would break every one of them.
|
|
430
|
+
*
|
|
431
|
+
* ALREADY SETTLED IS NOT RE-SETTLED. A payment carrying a `ProviderChargeID` has been through the
|
|
432
|
+
* gateway once. Re-saving it for any reason must not charge the customer again, so the presence of
|
|
433
|
+
* that reference is the idempotency key — the same shape as `JournalEntryID` for booking.
|
|
434
|
+
*
|
|
435
|
+
* THE GATEWAY'S NUMBERS WIN. `Amount` and `ProcessingFeeAmount` are overwritten from the capture
|
|
436
|
+
* result, because what moved is what moved. `SplitCapturedAmount` then checks the three reconcile
|
|
437
|
+
* before the fee leg is built, so a nonsensical fee is refused here rather than producing an entry
|
|
438
|
+
* that will not balance three calls later.
|
|
439
|
+
*/
|
|
440
|
+
async settleWithProvider() {
|
|
441
|
+
const providerID = this.PaymentProviderID;
|
|
442
|
+
if (!providerID)
|
|
443
|
+
return;
|
|
444
|
+
const alreadySettled = this.ProviderChargeID;
|
|
445
|
+
if (alreadySettled)
|
|
446
|
+
return;
|
|
447
|
+
const provider = this.ProviderToUse;
|
|
448
|
+
const user = this.ContextCurrentUser;
|
|
449
|
+
// THE GATEWAY'S INTENT STRING LIVES ON `PaymentIntent`, NOT HERE. `PaymentHeader.PaymentIntentID`
|
|
450
|
+
// is a foreign key to our row; `PaymentIntent.ProviderIntentID` is what Stripe calls it. Reading
|
|
451
|
+
// a `ProviderIntentID` off the header would compile — every column access here is a cast, since
|
|
452
|
+
// the generated base does not declare them — and be `undefined` at run time, refusing every
|
|
453
|
+
// provider-backed capture with a message about a missing intent.
|
|
454
|
+
const intent = await this.loadProviderIntent();
|
|
455
|
+
if (!intent) {
|
|
456
|
+
throw new Error(`Payment ${this.PaymentNumber} names a payment provider but no provider intent, so there ` +
|
|
457
|
+
`is nothing for the gateway to capture. Open an intent first, or clear ` +
|
|
458
|
+
`PaymentProviderID for a payment that is being recorded rather than collected.`);
|
|
459
|
+
}
|
|
460
|
+
const driver = await ResolvePaymentProvider(providerID, provider, user);
|
|
461
|
+
const capture = await driver.Capture({
|
|
462
|
+
ProviderIntentID: intent,
|
|
463
|
+
Amount: this.Amount ?? 0,
|
|
464
|
+
CurrencyCode: await this.functionalCurrency(),
|
|
465
|
+
});
|
|
466
|
+
if (!capture.Success) {
|
|
467
|
+
// A REFUSAL, not a fault — the card was declined, the intent was in the wrong state. It
|
|
468
|
+
// still fails the save, because a payment that did not capture must not be Captured; but it
|
|
469
|
+
// fails with the gateway's own words, which is what the person retrying needs.
|
|
470
|
+
throw new Error(`The gateway refused to capture payment ${this.PaymentNumber}: ` +
|
|
471
|
+
`${capture.Reason ?? 'no reason given'}`);
|
|
472
|
+
}
|
|
473
|
+
// UNDEFINED means the driver could not determine a fee, which is NOT the same as no fee (see
|
|
474
|
+
// BasePaymentProvider). Keeping whatever was already on the row is the honest response: we do
|
|
475
|
+
// not know, so we do not overwrite what somebody may have entered.
|
|
476
|
+
const gross = capture.Amount ?? this.Amount ?? 0;
|
|
477
|
+
if (capture.FeeAmount != null) {
|
|
478
|
+
const split = SplitCapturedAmount(gross, capture.FeeAmount);
|
|
479
|
+
this.Amount = split.Gross;
|
|
480
|
+
this.ProcessingFeeAmount = split.Fee;
|
|
481
|
+
this.NetAmount = split.Net;
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
this.Amount = Math.round(gross * 100) / 100;
|
|
485
|
+
}
|
|
486
|
+
if (capture.ProviderChargeID) {
|
|
487
|
+
this.ProviderChargeID = capture.ProviderChargeID;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
/** The gateway's own intent string, from the `PaymentIntent` row this payment points at. */
|
|
491
|
+
async loadProviderIntent() {
|
|
492
|
+
const intentID = this.PaymentIntentID;
|
|
493
|
+
if (!intentID)
|
|
494
|
+
return null;
|
|
495
|
+
const rv = new RunView(this.ProviderToUse);
|
|
496
|
+
const result = await rv.RunView({
|
|
497
|
+
EntityName: 'MJ_BizApps_Orders: Payment Intents',
|
|
498
|
+
ExtraFilter: `ID = '${intentID}'`,
|
|
499
|
+
ResultType: 'simple',
|
|
500
|
+
}, this.ContextCurrentUser);
|
|
501
|
+
return result?.Results?.[0]?.ProviderIntentID ?? null;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* The currency to transact in.
|
|
505
|
+
*
|
|
506
|
+
* THERE IS NO CURRENCY COLUMN ON A PAYMENT, deliberately (MOD-4): these tables are single-currency,
|
|
507
|
+
* and the currency is a property of the COMPANY collecting the money. Reading it from the receiving
|
|
508
|
+
* company's accounting profile keeps that true rather than quietly introducing a second opinion.
|
|
509
|
+
*
|
|
510
|
+
* Defaults to USD only when the profile cannot be read, because a gateway call needs *some* code and
|
|
511
|
+
* refusing the capture over a missing profile row would be a worse failure than the wrong exponent —
|
|
512
|
+
* which, for USD against any other two-decimal currency, is not even wrong.
|
|
513
|
+
*/
|
|
514
|
+
async functionalCurrency() {
|
|
515
|
+
const rv = new RunView(this.ProviderToUse);
|
|
516
|
+
const result = await rv.RunView({
|
|
517
|
+
EntityName: 'MJ_BizApps_Accounting: Accounting Company Profiles',
|
|
518
|
+
ExtraFilter: `ID = '${this.ReceivingCompanyID}'`,
|
|
519
|
+
ResultType: 'simple',
|
|
520
|
+
}, this.ContextCurrentUser);
|
|
521
|
+
return result?.Results?.[0]?.FunctionalCurrencyCode ?? 'USD';
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Whether this payment's TENDER books its processor fee as its own ledger leg (D82).
|
|
525
|
+
*
|
|
526
|
+
* READ FROM `PaymentType.BookProcessingFeeInline`, which defaults to 0 for every tender. The fee
|
|
527
|
+
* is still read from the gateway and still stored on this row; this decides only whether it
|
|
528
|
+
* becomes a journal entry. See the migration for why off is the correct default rather than the
|
|
529
|
+
* timid one — a per-payment fee leg cannot reconcile to a statement, because the processor
|
|
530
|
+
* batches into payouts and deducts costs that never attach to any payment.
|
|
531
|
+
*
|
|
532
|
+
* A MISSING TENDER ANSWERS FALSE, LOUDLY. Booking a fee we could not justify is worse than
|
|
533
|
+
* omitting one the month-end accrual will pick up anyway, and refusing the whole capture over it
|
|
534
|
+
* would hold up real money for a reporting detail. This is the same posture `bookProcessingFee`
|
|
535
|
+
* already takes when no Processing Fee account is linked: say so plainly, book the payment, move on.
|
|
536
|
+
*
|
|
537
|
+
* READ FROM THE ENGINE CACHE, NOT A PER-CALL QUERY. This used to be a `RunView` naming the single
|
|
538
|
+
* column, which reads as a careful minimal query and is really a hidden dependency on CodeGen
|
|
539
|
+
* having run: `Fields` names a column that must exist in the base view and be registered as an
|
|
540
|
+
* `EntityField`, and when it is not, `RunView` does not throw — it returns unsuccessfully, this
|
|
541
|
+
* method takes its defensive branch, and the switch is silently stuck off. Payment types are a
|
|
542
|
+
* lookup of eleven rows read on every capture; they belong in {@link OrdersEngine}, where a
|
|
543
|
+
* missing field fails the load once at startup instead of every call site guessing a default.
|
|
544
|
+
*/
|
|
545
|
+
async feeBooksInline() {
|
|
546
|
+
await LoadOrdersEngine(this.ProviderToUse, this.ContextCurrentUser);
|
|
547
|
+
const tender = OrdersEngine.Instance.PaymentTypeByID(this.PaymentTypeID);
|
|
548
|
+
if (!tender) {
|
|
549
|
+
LogError(`Payment ${this.PaymentNumber}: payment type ${this.PaymentTypeID} is not in the orders ` +
|
|
550
|
+
`lookup cache, so whether this tender books its fee inline is unknown. No fee entry ` +
|
|
551
|
+
`will be booked; the processor cost is accrued at month end.`);
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
return tender.BookProcessingFeeInline === true;
|
|
555
|
+
}
|
|
556
|
+
async bookProcessingFee(options) {
|
|
557
|
+
const provider = this.ProviderToUse;
|
|
558
|
+
const user = this.ContextCurrentUser;
|
|
559
|
+
const factory = new PaymentJournalEntryFactory(await BuildGLAccountResolver(provider, user), EntityIDFor(PAYMENT_HEADER_ENTITY));
|
|
560
|
+
const { Draft, UnbookedFeeAmount } = await factory.BuildCaptureDraft({
|
|
561
|
+
PaymentID: this.ID,
|
|
562
|
+
PaymentNumber: this.PaymentNumber,
|
|
563
|
+
CompanyID: this.ReceivingCompanyID,
|
|
564
|
+
Amount: this.Amount ?? 0,
|
|
565
|
+
ProcessingFeeAmount: this.ProcessingFeeAmount ?? 0,
|
|
566
|
+
PaymentDate: this.PaymentDate ? new Date(this.PaymentDate) : new Date(),
|
|
567
|
+
IsReversal: this.Status === 'Refunded',
|
|
568
|
+
});
|
|
569
|
+
if (UnbookedFeeAmount) {
|
|
570
|
+
// Not fatal — the allocation entries still book the gross to Cash. But the bank
|
|
571
|
+
// position is then overstated by this much, so it must not pass silently.
|
|
572
|
+
LogError(`Payment ${this.PaymentNumber}: processing fee of ${UnbookedFeeAmount} was NOT booked ` +
|
|
573
|
+
`because no 'Processing Fee' GL account is linked for company ` +
|
|
574
|
+
`${this.ReceivingCompanyID}. Cash will read gross by that amount.`);
|
|
575
|
+
}
|
|
576
|
+
// No fee, or no account for it: there is nothing for the HEADER to book. The cash and
|
|
577
|
+
// receivable sides belong to the allocation (PaymentLineEntityServer), which is the
|
|
578
|
+
// earliest point the owning companies are known.
|
|
579
|
+
if (!Draft)
|
|
580
|
+
return;
|
|
581
|
+
const result = await this.createJournalEntries([Draft], provider, user);
|
|
582
|
+
const journalEntryID = result.Results?.[0]?.JournalEntryID;
|
|
583
|
+
if (!journalEntryID) {
|
|
584
|
+
throw new Error(`Accounting returned no journal entry for payment ${this.PaymentNumber}.`);
|
|
585
|
+
}
|
|
586
|
+
// NULL→value-once; the DB trigger enforces the same rule independently.
|
|
587
|
+
this.JournalEntryID = journalEntryID;
|
|
588
|
+
if (!(await super.Save(options))) {
|
|
589
|
+
throw new Error(`Failed to stamp JournalEntryID on payment ${this.PaymentNumber}: ` +
|
|
590
|
+
`${this.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
async createJournalEntries(drafts, provider, user) {
|
|
594
|
+
const op = MJGlobal.Instance.ClassFactory.CreateInstance(BaseRemotableOperation, 'Accounting.CreateJournalEntries');
|
|
595
|
+
if (!op) {
|
|
596
|
+
throw new Error(`The 'Accounting.CreateJournalEntries' operation is not registered. The BizApps ` +
|
|
597
|
+
`Accounting server package must be loaded before payments can book the cash leg.`);
|
|
598
|
+
}
|
|
599
|
+
const result = await op.Execute({ Drafts: drafts }, { provider, user });
|
|
600
|
+
if (!result.Success) {
|
|
601
|
+
throw new Error(`Accounting.CreateJournalEntries did not execute: ${result.ErrorMessage ?? result.ResultCode ?? 'unknown error'}`);
|
|
602
|
+
}
|
|
603
|
+
const payload = result.Output;
|
|
604
|
+
if (!payload) {
|
|
605
|
+
throw new Error('Accounting.CreateJournalEntries returned no payload.');
|
|
606
|
+
}
|
|
607
|
+
if (!payload.Success) {
|
|
608
|
+
const detail = (payload.Errors ?? [])
|
|
609
|
+
.map((e) => `${e.Code ?? 'ERROR'}: ${e.Message ?? ''}`)
|
|
610
|
+
.join('; ');
|
|
611
|
+
throw new Error(`Journal entry booking failed for payment ${this.PaymentNumber}. ${detail}`);
|
|
612
|
+
}
|
|
613
|
+
return payload;
|
|
614
|
+
}
|
|
615
|
+
async assignPaymentNumber() {
|
|
616
|
+
return `PAY-${String(await this.nextSequence('PaymentSequence')).padStart(6, '0')}`;
|
|
617
|
+
}
|
|
618
|
+
async nextSequence(table) {
|
|
619
|
+
const provider = this.ProviderToUse;
|
|
620
|
+
const rows = (await provider.ExecuteSQL(`DECLARE @seq TABLE (Seq INT);
|
|
621
|
+
UPDATE __mj_BizAppsOrders.${table} WITH (UPDLOCK, HOLDLOCK)
|
|
622
|
+
SET NextSequenceNumber = NextSequenceNumber + 1
|
|
623
|
+
OUTPUT deleted.NextSequenceNumber INTO @seq(Seq)
|
|
624
|
+
WHERE ID = 1;
|
|
625
|
+
SELECT Seq FROM @seq;`));
|
|
626
|
+
const list = Array.isArray(rows) ? rows : (rows?.Results ?? []);
|
|
627
|
+
const val = list[0]?.Seq;
|
|
628
|
+
if (typeof val !== 'number' || val <= 0) {
|
|
629
|
+
throw new Error(`Failed to assign sequence number from ${table}: unexpected return ${JSON.stringify(rows)}`);
|
|
630
|
+
}
|
|
631
|
+
return val;
|
|
632
|
+
}
|
|
633
|
+
buildFailureResult(err) {
|
|
634
|
+
// Same shape as OrderEntityServer's (D50): a bare `false` leaves LatestResult holding the
|
|
635
|
+
// row's SUCCESSFUL save, so the caller sees no reason for the refusal.
|
|
636
|
+
const result = new BaseEntityResult();
|
|
637
|
+
result.Success = false;
|
|
638
|
+
result.Type = this.IsSaved ? 'update' : 'create';
|
|
639
|
+
result.Message = err instanceof Error ? err.message : String(err);
|
|
640
|
+
result.Error = err;
|
|
641
|
+
result.OriginalValues = this.Fields.map((f) => ({ FieldName: f.Name, Value: f.OldValue }));
|
|
642
|
+
result.NewValues = this.Fields.map((f) => ({ FieldName: f.Name, Value: f.Value }));
|
|
643
|
+
result.StartedAt = new Date();
|
|
644
|
+
result.EndedAt = new Date();
|
|
645
|
+
return result;
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
PaymentHeaderEntityServer = __decorate([
|
|
649
|
+
RegisterClass(BaseEntity, PAYMENT_HEADER_ENTITY)
|
|
650
|
+
], PaymentHeaderEntityServer);
|
|
651
|
+
export { PaymentHeaderEntityServer };
|
|
652
|
+
/** Tree-shaking anchor — booking lives in Save, which is only reachable via the registration. */
|
|
653
|
+
export function LoadPaymentHeaderEntityServer() {
|
|
654
|
+
// intentionally empty
|
|
655
|
+
}
|
|
656
|
+
//# sourceMappingURL=PaymentHeaderEntityServer.js.map
|