@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,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promoting a bank debit into cash — the only place a webhook is allowed to move money.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS AT ALL. `PaymentWebhookHandler` was written deliberately narrow: it records what the
|
|
5
|
+
* gateway said on the `PaymentIntent` and stops there, because a webhook reaching into the ledger
|
|
6
|
+
* would give an unauthenticated endpoint a second path into the books. That was exactly right for
|
|
7
|
+
* cards, where the money has already moved by the time anyone asks and the event only confirms it.
|
|
8
|
+
*
|
|
9
|
+
* A bank debit breaks the assumption underneath it. Nothing has moved when the caller asks; the answer
|
|
10
|
+
* arrives days later, in an event, and there is no other moment at which cash can honestly be booked.
|
|
11
|
+
* So the webhook has to be able to move money — and this module is that capability, kept in one file,
|
|
12
|
+
* reachable only when a driver has declared `SettlesAsynchronously`, and doing exactly three things.
|
|
13
|
+
*
|
|
14
|
+
* IT DOES NOT BOOK ANYTHING ITSELF. That is the part worth being clear about. `Promote` sets a status
|
|
15
|
+
* and saves; `PaymentHeaderEntityServer` notices the transition into `Captured` and books the cash
|
|
16
|
+
* through the same path a card capture uses — including calling back to the driver to ask what
|
|
17
|
+
* actually moved. `Reverse` writes a reversing payment through the same factory `Orders.RefundPayment`
|
|
18
|
+
* uses. Nothing here writes a journal entry, and nothing here knows how one is shaped. The ledger keeps
|
|
19
|
+
* a single author.
|
|
20
|
+
*
|
|
21
|
+
* ORDERING MATTERS, AND IT IS NOT THE OBVIOUS ONE. This runs BEFORE the handler stamps
|
|
22
|
+
* `PaymentIntent.ProviderEventID`, not after. Stamping first would look tidier and would silently
|
|
23
|
+
* strand payments: the stamp is the idempotency key, so an event that was recorded and then failed to
|
|
24
|
+
* settle would be judged `AlreadyApplied` on every retry, and a payment the bank confirmed would sit
|
|
25
|
+
* `Pending` forever with nothing left to move it. Settling first means a failure returns 500 with
|
|
26
|
+
* nothing stamped, the gateway retries, and both steps run again. The re-run is safe because
|
|
27
|
+
* `DecideSettlement` reads the PAYMENT'S state rather than the event's novelty — a promotion that
|
|
28
|
+
* already happened decides `None`.
|
|
29
|
+
*
|
|
30
|
+
* CONNECTS TO:
|
|
31
|
+
* PURE: ./PaymentProviderBehavior.ts — DecideSettlement, the decision table
|
|
32
|
+
* ROUTE: ./PaymentWebhookHandler.ts — the only caller
|
|
33
|
+
* WRITES: ./PaymentHeaderEntityServer.ts (promote) · ./PaymentReversalFactory.ts (reverse)
|
|
34
|
+
* DOC: plans/archive/bizapps-orders-master.md D17, D19, D53
|
|
35
|
+
*/
|
|
36
|
+
import { CompositeKey, LogError, LogStatus, RunView, } from '@memberjunction/core';
|
|
37
|
+
import { DecideSettlement } from './PaymentProviderBehavior.js';
|
|
38
|
+
import { BuildUnapplyLines, CreateReversingPayment, LoadAppliedAllocations, } from './PaymentReversalFactory.js';
|
|
39
|
+
const PAYMENT_HEADER_ENTITY = 'MJ_BizApps_Orders: Payment Headers';
|
|
40
|
+
const PAYMENT_LINE_ENTITY = 'MJ_BizApps_Orders: Payment Lines';
|
|
41
|
+
/**
|
|
42
|
+
* Apply a settlement event to the payment behind its intent.
|
|
43
|
+
*
|
|
44
|
+
* THROWS ON FAULTS, DELIBERATELY. A refusal here is not a business outcome to be reported and
|
|
45
|
+
* forgotten — it means the bank told us something about real money and we failed to record it. The
|
|
46
|
+
* caller turns a throw into a 500 so the gateway asks again, which is the only way that notice is not
|
|
47
|
+
* lost. Decisions that legitimately do nothing (`None`, `Hold`) return normally.
|
|
48
|
+
*/
|
|
49
|
+
export async function SettlePaymentForEvent(event, paymentIntentID, provider, user) {
|
|
50
|
+
const payment = await loadPaymentForIntent(provider, user, paymentIntentID);
|
|
51
|
+
const alreadyReversed = payment ? await hasReversal(provider, user, payment.ID) : false;
|
|
52
|
+
// WHETHER THIS PAYMENT PUT CASH IN THE LEDGER IS A QUESTION ABOUT ITS ALLOCATIONS, NOT ITS HEADER.
|
|
53
|
+
//
|
|
54
|
+
// This read used to be `Boolean(payment.JournalEntryID)`, and that stopped being the same question
|
|
55
|
+
// twice over. The cash leg moved to the allocation (D13), so the header's journal entry is only
|
|
56
|
+
// ever the PROCESSING FEE — and D82 turned the fee entry off for every tender by default. Between
|
|
57
|
+
// them, `JournalEntryID` is now null on essentially every payment that ever books.
|
|
58
|
+
//
|
|
59
|
+
// The decision table reads `HeaderBooked` to choose between reversing a returned debit and holding
|
|
60
|
+
// it for a person ("Captured but carries no journal entry, so what to reverse is unclear"). With
|
|
61
|
+
// the old signal every returned ACH debit took the Hold branch: the cash stayed in the ledger, the
|
|
62
|
+
// order stayed marked paid, and the only outward sign was a settlement waiting on a human who was
|
|
63
|
+
// never told. Asking the allocations restores the question the table was written to ask.
|
|
64
|
+
const booked = payment ? await hasBookedAllocations(provider, user, payment.ID) : false;
|
|
65
|
+
const decision = DecideSettlement({
|
|
66
|
+
EventStatus: event.Status,
|
|
67
|
+
HeaderStatus: payment?.Status,
|
|
68
|
+
HeaderBooked: booked,
|
|
69
|
+
AlreadyReversed: alreadyReversed,
|
|
70
|
+
});
|
|
71
|
+
const base = {
|
|
72
|
+
Action: decision.Action,
|
|
73
|
+
Reason: decision.Reason,
|
|
74
|
+
PaymentHeaderID: payment?.ID,
|
|
75
|
+
};
|
|
76
|
+
switch (decision.Action) {
|
|
77
|
+
case 'Promote':
|
|
78
|
+
await promote(provider, user, payment, event);
|
|
79
|
+
LogStatus(`Payment ${payment.PaymentNumber} captured from bank-debit event ${event.EventID}.`);
|
|
80
|
+
return base;
|
|
81
|
+
case 'Fail':
|
|
82
|
+
await markFailed(provider, user, payment, event);
|
|
83
|
+
LogStatus(`Payment ${payment.PaymentNumber} failed from bank-debit event ${event.EventID}` +
|
|
84
|
+
`${event.FailureReason ? ` (${event.FailureReason})` : ''}.`);
|
|
85
|
+
return base;
|
|
86
|
+
case 'Reverse': {
|
|
87
|
+
const reversalID = await reverse(provider, user, payment, event);
|
|
88
|
+
LogStatus(`Payment ${payment.PaymentNumber} was returned by the bank and has been reversed ` +
|
|
89
|
+
`from event ${event.EventID}.`);
|
|
90
|
+
return { ...base, ReversalPaymentHeaderID: reversalID };
|
|
91
|
+
}
|
|
92
|
+
case 'Hold':
|
|
93
|
+
// LOUD, because nothing else will be. A held event is money whose state we have declined to
|
|
94
|
+
// guess at, and the only thing standing between that and a silent discrepancy is this line.
|
|
95
|
+
LogError(`Bank-debit event ${event.EventID} was NOT applied to payment ` +
|
|
96
|
+
`${payment?.PaymentNumber ?? '(none)'}: ${decision.Reason}. This needs a person.`);
|
|
97
|
+
return base;
|
|
98
|
+
default:
|
|
99
|
+
LogStatus(`Bank-debit event ${event.EventID}: ${decision.Reason}`);
|
|
100
|
+
return base;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// ─── Reads ─────────────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
async function loadPaymentForIntent(provider, user, paymentIntentID) {
|
|
105
|
+
const rv = new RunView(provider);
|
|
106
|
+
const result = await rv.RunView({
|
|
107
|
+
EntityName: PAYMENT_HEADER_ENTITY,
|
|
108
|
+
ExtraFilter: `PaymentIntentID='${paymentIntentID}'`,
|
|
109
|
+
Fields: [
|
|
110
|
+
'ID',
|
|
111
|
+
'PaymentNumber',
|
|
112
|
+
'ReceivingCompanyID',
|
|
113
|
+
'BillToOrganizationID',
|
|
114
|
+
'BillToPersonID',
|
|
115
|
+
'PaymentTypeID',
|
|
116
|
+
'PaymentDetailID',
|
|
117
|
+
'Amount',
|
|
118
|
+
'Status',
|
|
119
|
+
'JournalEntryID',
|
|
120
|
+
],
|
|
121
|
+
ResultType: 'simple',
|
|
122
|
+
// The status is the whole decision, and it may have been written by the previous delivery
|
|
123
|
+
// of a related event moments ago. A cached read here re-promotes an already-captured
|
|
124
|
+
// payment or reverses one twice.
|
|
125
|
+
BypassCache: true,
|
|
126
|
+
}, user);
|
|
127
|
+
return result?.Results?.[0] ?? null;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* True when any of this payment's allocations has booked — i.e. the cash is in the ledger.
|
|
131
|
+
*
|
|
132
|
+
* `PaymentLine.BookedAt` is the allocation's own idempotency key, set in the same transaction that
|
|
133
|
+
* writes its journal entry, so it is the authority on whether the money exists. Reading one booked
|
|
134
|
+
* row is enough: allocations book together with the capture.
|
|
135
|
+
*
|
|
136
|
+
* `BypassCache` for the same reason the header read has it — the promotion that booked these lines
|
|
137
|
+
* may have happened moments ago, on a previous delivery of a related event.
|
|
138
|
+
*/
|
|
139
|
+
async function hasBookedAllocations(provider, user, paymentID) {
|
|
140
|
+
const rv = new RunView(provider);
|
|
141
|
+
const result = await rv.RunView({
|
|
142
|
+
EntityName: PAYMENT_LINE_ENTITY,
|
|
143
|
+
ExtraFilter: `PaymentHeaderID='${paymentID}' AND BookedAt IS NOT NULL`,
|
|
144
|
+
Fields: ['ID'],
|
|
145
|
+
MaxRows: 1,
|
|
146
|
+
ResultType: 'simple',
|
|
147
|
+
BypassCache: true,
|
|
148
|
+
}, user);
|
|
149
|
+
return (result?.Results?.length ?? 0) > 0;
|
|
150
|
+
}
|
|
151
|
+
/** True when a reversing payment already points at this one. */
|
|
152
|
+
async function hasReversal(provider, user, paymentID) {
|
|
153
|
+
const rv = new RunView(provider);
|
|
154
|
+
const result = await rv.RunView({
|
|
155
|
+
EntityName: PAYMENT_HEADER_ENTITY,
|
|
156
|
+
ExtraFilter: `ReversesPaymentHeaderID='${paymentID}' AND Status='Refunded'`,
|
|
157
|
+
Fields: ['ID'],
|
|
158
|
+
ResultType: 'simple',
|
|
159
|
+
BypassCache: true,
|
|
160
|
+
}, user);
|
|
161
|
+
return (result?.Results?.length ?? 0) > 0;
|
|
162
|
+
}
|
|
163
|
+
// ─── Writes ────────────────────────────────────────────────────────────────────────────────────
|
|
164
|
+
/**
|
|
165
|
+
* Pending → Captured, which is what books the cash.
|
|
166
|
+
*
|
|
167
|
+
* NOTHING IS SET BUT THE STATUS. `PaymentHeaderEntityServer` calls the driver on this transition to
|
|
168
|
+
* ask what actually moved, and overwrites `Amount`, `ProcessingFeeAmount` and `NetAmount` from the
|
|
169
|
+
* answer. Setting them from the webhook payload here would put our reading of the event in a race with
|
|
170
|
+
* the gateway's own record of the charge — and the gateway wins that argument every time, so writing
|
|
171
|
+
* ours would be work whose only possible outcome is being overwritten or being wrong.
|
|
172
|
+
*/
|
|
173
|
+
async function promote(provider, user, payment, event) {
|
|
174
|
+
const header = await provider.GetEntityObject(PAYMENT_HEADER_ENTITY, CompositeKey.FromID(payment.ID), user);
|
|
175
|
+
header.Status = 'Captured';
|
|
176
|
+
if (!(await header.Save())) {
|
|
177
|
+
throw new Error(`Could not capture payment ${payment.PaymentNumber} from event ${event.EventID}: ` +
|
|
178
|
+
`${header.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Pending → Failed. Nothing was booked, so nothing needs reversing.
|
|
183
|
+
*
|
|
184
|
+
* The bank's own words go on `Notes` rather than `ReversalReason` — that column belongs to reversals,
|
|
185
|
+
* and a failed payment did not reverse anything. Appending rather than replacing keeps whatever a
|
|
186
|
+
* person had already written there.
|
|
187
|
+
*/
|
|
188
|
+
async function markFailed(provider, user, payment, event) {
|
|
189
|
+
const header = await provider.GetEntityObject(PAYMENT_HEADER_ENTITY, CompositeKey.FromID(payment.ID), user);
|
|
190
|
+
header.Status = 'Failed';
|
|
191
|
+
if (event.FailureReason) {
|
|
192
|
+
const existing = header.Notes ?? '';
|
|
193
|
+
const note = `Bank debit did not clear: ${event.FailureReason}`;
|
|
194
|
+
header.Notes = existing ? `${existing}\n${note}` : note;
|
|
195
|
+
}
|
|
196
|
+
if (!(await header.Save())) {
|
|
197
|
+
throw new Error(`Could not mark payment ${payment.PaymentNumber} as failed from event ${event.EventID}: ` +
|
|
198
|
+
`${header.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Captured → reversed by a new payment, because the bank took the money back.
|
|
203
|
+
*
|
|
204
|
+
* THE FULL AMOUNT, ALWAYS. A return is not a partial refund — the bank does not return part of a
|
|
205
|
+
* debit — so the reversal mirrors what was booked rather than anything on the event. Reading the
|
|
206
|
+
* amount off the event would be worse than useless here: a `charge.failed` payload reports the
|
|
207
|
+
* charge's amount, which is the same number when all is well and a silent under-reversal when it is
|
|
208
|
+
* not.
|
|
209
|
+
*/
|
|
210
|
+
async function reverse(provider, user, payment, event) {
|
|
211
|
+
const applications = await LoadAppliedAllocations(provider, user, payment.ID);
|
|
212
|
+
if (!applications.length) {
|
|
213
|
+
throw new Error(`Payment ${payment.PaymentNumber} was returned by the bank but is not applied to any order, ` +
|
|
214
|
+
`so there is nothing to un-apply. This should be impossible for a captured payment.`);
|
|
215
|
+
}
|
|
216
|
+
const amount = Math.round(Math.abs(Number(payment.Amount ?? 0)) * 100) / 100;
|
|
217
|
+
const reason = event.FailureReason
|
|
218
|
+
? `Bank debit returned: ${event.FailureReason}`
|
|
219
|
+
: 'Bank debit returned by the customer’s bank';
|
|
220
|
+
const dbProvider = provider;
|
|
221
|
+
await dbProvider.BeginTransaction();
|
|
222
|
+
try {
|
|
223
|
+
const lines = await BuildUnapplyLines(provider, user, applications, amount);
|
|
224
|
+
const reversal = await CreateReversingPayment(provider, user, payment, {
|
|
225
|
+
Amount: amount,
|
|
226
|
+
Reason: reason,
|
|
227
|
+
ProviderRefundID: event.ProviderChargeID ?? null,
|
|
228
|
+
Description: `Return of ${payment.PaymentNumber}`,
|
|
229
|
+
}, lines);
|
|
230
|
+
await dbProvider.CommitTransaction();
|
|
231
|
+
return reversal.ID;
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
try {
|
|
235
|
+
await dbProvider.RollbackTransaction();
|
|
236
|
+
}
|
|
237
|
+
catch (rollbackErr) {
|
|
238
|
+
LogError(`Rollback failed after a bank-debit reversal error: ${rollbackErr}`);
|
|
239
|
+
}
|
|
240
|
+
throw err;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
//# sourceMappingURL=PaymentSettlement.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PaymentSettlement.js","sourceRoot":"","sources":["../src/PaymentSettlement.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview When an order is due — the resolution walk, with no database (plan D83).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS AT ALL. `OrderHeader.DueDate` was only ever what a caller passed, and nothing
|
|
5
|
+
* derived it from `PaymentTermsType.NetDays` even though the schema comment promised exactly that.
|
|
6
|
+
* `PaymentTermsType` had no rows, so nobody could pick terms either. The consequence was not a
|
|
7
|
+
* missing feature that looked missing:
|
|
8
|
+
*
|
|
9
|
+
* Orders.GetOverdueWorklist, as of 2026-12-31 → 0 rows, £0 overdue, every aging bucket zero
|
|
10
|
+
*
|
|
11
|
+
* against 67 orders carrying an unpaid balance. The collections surface reported a quiet afternoon
|
|
12
|
+
* because its only input was null on every row. Aging, the overdue worklist and the invoice's due
|
|
13
|
+
* date all read that one column.
|
|
14
|
+
*
|
|
15
|
+
* THE WALK, and it is the third of this shape in the app — GL account resolution (D5) and price
|
|
16
|
+
* resolution (D69) are the other two, so a reader already knows how to read it:
|
|
17
|
+
*
|
|
18
|
+
* 1. a STATED DueDate the caller knows the answer; never recomputed
|
|
19
|
+
* 2. a STATED PaymentTermsTypeID derive OrderDate + NetDays
|
|
20
|
+
* 3. the CUSTOMER's terms CustomerPaymentTerms, date-effective, optionally per company
|
|
21
|
+
* 4. the SELLING COMPANY's default AccountingCompanyProfile.DefaultPaymentTermsTypeID
|
|
22
|
+
* 5. due on receipt the terminal default
|
|
23
|
+
*
|
|
24
|
+
* WHERE CONTRACTS FIT. They do not — deliberately. A contracts app further down the graph populates
|
|
25
|
+
* the order's `DueDate` or `PaymentTermsTypeID` directly, and to Orders that is simply rung 1 or 2.
|
|
26
|
+
* Orders has no knowledge of contracts and should not grow any; "stated" is the whole interface.
|
|
27
|
+
*
|
|
28
|
+
* WHICH IS WHY STATED HAS TO BE RECORDED AS STATED. A due date that was supplied and one that was
|
|
29
|
+
* derived are the same value in the same column, and the difference only shows up on the next save —
|
|
30
|
+
* when a recompute silently moves a date somebody negotiated. `PricingBehavior` already draws this
|
|
31
|
+
* distinction for a stated unit price; this follows it.
|
|
32
|
+
*
|
|
33
|
+
* UNLIKE D5, IT DOES NOT FAIL LOUDLY. An unresolvable GL account means booked money with nowhere to
|
|
34
|
+
* go, so refusing is right. Missing terms have a sane answer — due on receipt — and refusing an
|
|
35
|
+
* order because nobody configured a lookup would be hostile for no gain.
|
|
36
|
+
*
|
|
37
|
+
* @module @mj-biz-apps/orders-core-entities-server
|
|
38
|
+
*/
|
|
39
|
+
import { type DateCell } from '@mj-biz-apps/orders-entities';
|
|
40
|
+
/** A terms record as the walk needs it. */
|
|
41
|
+
export interface TermsFacts {
|
|
42
|
+
PaymentTermsTypeID: string;
|
|
43
|
+
/** Days from the order date to the due date. 0 means due on receipt. */
|
|
44
|
+
NetDays: number | null;
|
|
45
|
+
}
|
|
46
|
+
/** One rung 3 candidate: what a particular buyer negotiated. */
|
|
47
|
+
export interface CustomerTermsFacts extends TermsFacts {
|
|
48
|
+
/** Null when the terms apply whoever is selling. */
|
|
49
|
+
CompanyID: string | null;
|
|
50
|
+
StartedAt: DateCell;
|
|
51
|
+
EndedAt: DateCell;
|
|
52
|
+
Status: string;
|
|
53
|
+
}
|
|
54
|
+
/** Everything the walk may consult, gathered by the caller. */
|
|
55
|
+
export interface TermsResolutionInput {
|
|
56
|
+
/** What the caller stated on the order, if anything. */
|
|
57
|
+
StatedDueDate: DateCell;
|
|
58
|
+
StatedPaymentTermsTypeID: string | null;
|
|
59
|
+
/** The date the terms run from. */
|
|
60
|
+
OrderDate: string;
|
|
61
|
+
/** The selling company, used to narrow customer terms and to find the company default. */
|
|
62
|
+
CompanyID: string | null;
|
|
63
|
+
/** Rung 3 candidates for this buyer, in any order. */
|
|
64
|
+
CustomerTerms: readonly CustomerTermsFacts[];
|
|
65
|
+
/** Rung 4 — the selling company's default, or null when it has no profile. */
|
|
66
|
+
CompanyDefault: TermsFacts | null;
|
|
67
|
+
/** `NetDays` by terms id, for resolving a stated `PaymentTermsTypeID`. */
|
|
68
|
+
TermsByID: ReadonlyMap<string, TermsFacts>;
|
|
69
|
+
}
|
|
70
|
+
export type TermsSource = 'StatedDueDate' | 'StatedTerms' | 'CustomerTerms' | 'CompanyDefault' | 'DueOnReceipt';
|
|
71
|
+
export interface TermsResolution {
|
|
72
|
+
/** `YYYY-MM-DD`, or null only when the order date itself was unusable. */
|
|
73
|
+
DueDate: string | null;
|
|
74
|
+
/** The terms record the date came from, when one was involved. */
|
|
75
|
+
PaymentTermsTypeID: string | null;
|
|
76
|
+
/** Which rung answered — recorded so a later save can tell stated from derived. */
|
|
77
|
+
Source: TermsSource;
|
|
78
|
+
/** True when the caller supplied the date and it must never be recomputed. */
|
|
79
|
+
WasStated: boolean;
|
|
80
|
+
}
|
|
81
|
+
/** `from` plus `days`, as `YYYY-MM-DD`. */
|
|
82
|
+
export declare function AddDays(from: DateCell, days: number): string | null;
|
|
83
|
+
/**
|
|
84
|
+
* Whether a customer-terms row applies to this order.
|
|
85
|
+
*
|
|
86
|
+
* DATE-EFFECTIVE ON THE ORDER DATE, not on today. Renegotiating terms must not restate what an old
|
|
87
|
+
* order was due on — an order placed under Net 30 stays due when it was always due, however the
|
|
88
|
+
* relationship changed afterwards.
|
|
89
|
+
*
|
|
90
|
+
* A row scoped to a company applies only to that company's orders; an unscoped row applies to any.
|
|
91
|
+
*/
|
|
92
|
+
export declare function CustomerTermsApply(row: CustomerTermsFacts, orderDate: DateCell, companyID: string | null): boolean;
|
|
93
|
+
/**
|
|
94
|
+
* The most specific applicable customer terms.
|
|
95
|
+
*
|
|
96
|
+
* COMPANY-SCOPED BEATS UNSCOPED, because a subsidiary that negotiated its own terms meant to
|
|
97
|
+
* override the group's. Among equally specific rows the one that started most recently wins — a
|
|
98
|
+
* later negotiation supersedes an earlier one — and a row with no start date is treated as having
|
|
99
|
+
* always applied, so it loses to any dated row.
|
|
100
|
+
*/
|
|
101
|
+
export declare function BestCustomerTerms(rows: readonly CustomerTermsFacts[], orderDate: string, companyID: string | null): CustomerTermsFacts | null;
|
|
102
|
+
/**
|
|
103
|
+
* Walk the rungs and produce the due date.
|
|
104
|
+
*
|
|
105
|
+
* Never throws and never returns "unknown": every order gets a date or an explicit due-on-receipt,
|
|
106
|
+
* which is the order date itself.
|
|
107
|
+
*/
|
|
108
|
+
export declare function ResolveDueDate(input: TermsResolutionInput): TermsResolution;
|
|
109
|
+
//# sourceMappingURL=PaymentTermsBehavior.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PaymentTermsBehavior.d.ts","sourceRoot":"","sources":["../src/PaymentTermsBehavior.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,OAAO,EAAa,KAAK,QAAQ,EAAE,MAAM,8BAA8B,CAAC;AAExE,2CAA2C;AAC3C,MAAM,WAAW,UAAU;IACvB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,wEAAwE;IACxE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,gEAAgE;AAChE,MAAM,WAAW,kBAAmB,SAAQ,UAAU;IAClD,oDAAoD;IACpD,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,EAAE,QAAQ,CAAC;IACpB,OAAO,EAAE,QAAQ,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CAClB;AAED,+DAA+D;AAC/D,MAAM,WAAW,oBAAoB;IACjC,wDAAwD;IACxD,aAAa,EAAE,QAAQ,CAAC;IACxB,wBAAwB,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,mCAAmC;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,0FAA0F;IAC1F,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,sDAAsD;IACtD,aAAa,EAAE,SAAS,kBAAkB,EAAE,CAAC;IAC7C,8EAA8E;IAC9E,cAAc,EAAE,UAAU,GAAG,IAAI,CAAC;IAClC,0EAA0E;IAC1E,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;CAC9C;AAED,MAAM,MAAM,WAAW,GAAG,eAAe,GAAG,aAAa,GAAG,eAAe,GAAG,gBAAgB,GAAG,cAAc,CAAC;AAEhH,MAAM,WAAW,eAAe;IAC5B,0EAA0E;IAC1E,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,kEAAkE;IAClE,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,mFAAmF;IACnF,MAAM,EAAE,WAAW,CAAC;IACpB,8EAA8E;IAC9E,SAAS,EAAE,OAAO,CAAC;CACtB;AAkBD,2CAA2C;AAC3C,wBAAgB,OAAO,CAAC,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAInE;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,kBAAkB,EAAE,SAAS,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAYlH;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC7B,IAAI,EAAE,SAAS,kBAAkB,EAAE,EACnC,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,GAAG,IAAI,GACzB,kBAAkB,GAAG,IAAI,CAa3B;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,oBAAoB,GAAG,eAAe,CAwD3E"}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview When an order is due — the resolution walk, with no database (plan D83).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS AT ALL. `OrderHeader.DueDate` was only ever what a caller passed, and nothing
|
|
5
|
+
* derived it from `PaymentTermsType.NetDays` even though the schema comment promised exactly that.
|
|
6
|
+
* `PaymentTermsType` had no rows, so nobody could pick terms either. The consequence was not a
|
|
7
|
+
* missing feature that looked missing:
|
|
8
|
+
*
|
|
9
|
+
* Orders.GetOverdueWorklist, as of 2026-12-31 → 0 rows, £0 overdue, every aging bucket zero
|
|
10
|
+
*
|
|
11
|
+
* against 67 orders carrying an unpaid balance. The collections surface reported a quiet afternoon
|
|
12
|
+
* because its only input was null on every row. Aging, the overdue worklist and the invoice's due
|
|
13
|
+
* date all read that one column.
|
|
14
|
+
*
|
|
15
|
+
* THE WALK, and it is the third of this shape in the app — GL account resolution (D5) and price
|
|
16
|
+
* resolution (D69) are the other two, so a reader already knows how to read it:
|
|
17
|
+
*
|
|
18
|
+
* 1. a STATED DueDate the caller knows the answer; never recomputed
|
|
19
|
+
* 2. a STATED PaymentTermsTypeID derive OrderDate + NetDays
|
|
20
|
+
* 3. the CUSTOMER's terms CustomerPaymentTerms, date-effective, optionally per company
|
|
21
|
+
* 4. the SELLING COMPANY's default AccountingCompanyProfile.DefaultPaymentTermsTypeID
|
|
22
|
+
* 5. due on receipt the terminal default
|
|
23
|
+
*
|
|
24
|
+
* WHERE CONTRACTS FIT. They do not — deliberately. A contracts app further down the graph populates
|
|
25
|
+
* the order's `DueDate` or `PaymentTermsTypeID` directly, and to Orders that is simply rung 1 or 2.
|
|
26
|
+
* Orders has no knowledge of contracts and should not grow any; "stated" is the whole interface.
|
|
27
|
+
*
|
|
28
|
+
* WHICH IS WHY STATED HAS TO BE RECORDED AS STATED. A due date that was supplied and one that was
|
|
29
|
+
* derived are the same value in the same column, and the difference only shows up on the next save —
|
|
30
|
+
* when a recompute silently moves a date somebody negotiated. `PricingBehavior` already draws this
|
|
31
|
+
* distinction for a stated unit price; this follows it.
|
|
32
|
+
*
|
|
33
|
+
* UNLIKE D5, IT DOES NOT FAIL LOUDLY. An unresolvable GL account means booked money with nowhere to
|
|
34
|
+
* go, so refusing is right. Missing terms have a sane answer — due on receipt — and refusing an
|
|
35
|
+
* order because nobody configured a lookup would be hostile for no gain.
|
|
36
|
+
*
|
|
37
|
+
* @module @mj-biz-apps/orders-core-entities-server
|
|
38
|
+
*/
|
|
39
|
+
import { ToISODate } from '@mj-biz-apps/orders-entities';
|
|
40
|
+
/**
|
|
41
|
+
* ISO date arithmetic that does not drift across a DST boundary.
|
|
42
|
+
*
|
|
43
|
+
* Takes the cell in whatever shape it arrived. The previous form split `String(iso)` on '-', which
|
|
44
|
+
* on a `Date` finds nothing to split and yields `NaN` — so every date-effective terms row silently
|
|
45
|
+
* stopped applying and the order fell through to the company default. It failed safe rather than
|
|
46
|
+
* wrong, but it still restated what a customer had negotiated.
|
|
47
|
+
*/
|
|
48
|
+
function dayNumber(cell) {
|
|
49
|
+
const iso = ToISODate(cell);
|
|
50
|
+
if (!iso)
|
|
51
|
+
return Number.NaN;
|
|
52
|
+
const [y, m, d] = iso.split('-').map(Number);
|
|
53
|
+
if (!y || !m || !d)
|
|
54
|
+
return Number.NaN;
|
|
55
|
+
return Date.UTC(y, m - 1, d);
|
|
56
|
+
}
|
|
57
|
+
/** `from` plus `days`, as `YYYY-MM-DD`. */
|
|
58
|
+
export function AddDays(from, days) {
|
|
59
|
+
const base = dayNumber(from);
|
|
60
|
+
if (Number.isNaN(base))
|
|
61
|
+
return null;
|
|
62
|
+
return new Date(base + days * 86_400_000).toISOString().slice(0, 10);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Whether a customer-terms row applies to this order.
|
|
66
|
+
*
|
|
67
|
+
* DATE-EFFECTIVE ON THE ORDER DATE, not on today. Renegotiating terms must not restate what an old
|
|
68
|
+
* order was due on — an order placed under Net 30 stays due when it was always due, however the
|
|
69
|
+
* relationship changed afterwards.
|
|
70
|
+
*
|
|
71
|
+
* A row scoped to a company applies only to that company's orders; an unscoped row applies to any.
|
|
72
|
+
*/
|
|
73
|
+
export function CustomerTermsApply(row, orderDate, companyID) {
|
|
74
|
+
if (row.Status !== 'Active')
|
|
75
|
+
return false;
|
|
76
|
+
if (row.CompanyID && companyID && row.CompanyID.toLowerCase() !== companyID.toLowerCase())
|
|
77
|
+
return false;
|
|
78
|
+
// A row scoped to a company cannot apply to an order with no company at all.
|
|
79
|
+
if (row.CompanyID && !companyID)
|
|
80
|
+
return false;
|
|
81
|
+
const on = dayNumber(orderDate);
|
|
82
|
+
if (Number.isNaN(on))
|
|
83
|
+
return false;
|
|
84
|
+
if (row.StartedAt && on < dayNumber(row.StartedAt))
|
|
85
|
+
return false;
|
|
86
|
+
// `EndedAt` is exclusive: terms that ended on the 1st do not cover an order placed on the 1st.
|
|
87
|
+
if (row.EndedAt && on >= dayNumber(row.EndedAt))
|
|
88
|
+
return false;
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The most specific applicable customer terms.
|
|
93
|
+
*
|
|
94
|
+
* COMPANY-SCOPED BEATS UNSCOPED, because a subsidiary that negotiated its own terms meant to
|
|
95
|
+
* override the group's. Among equally specific rows the one that started most recently wins — a
|
|
96
|
+
* later negotiation supersedes an earlier one — and a row with no start date is treated as having
|
|
97
|
+
* always applied, so it loses to any dated row.
|
|
98
|
+
*/
|
|
99
|
+
export function BestCustomerTerms(rows, orderDate, companyID) {
|
|
100
|
+
const applicable = rows.filter((r) => CustomerTermsApply(r, orderDate, companyID));
|
|
101
|
+
if (!applicable.length)
|
|
102
|
+
return null;
|
|
103
|
+
return applicable.reduce((best, row) => {
|
|
104
|
+
const bestScoped = best.CompanyID ? 1 : 0;
|
|
105
|
+
const rowScoped = row.CompanyID ? 1 : 0;
|
|
106
|
+
if (rowScoped !== bestScoped)
|
|
107
|
+
return rowScoped > bestScoped ? row : best;
|
|
108
|
+
const bestStart = best.StartedAt ? dayNumber(best.StartedAt) : Number.NEGATIVE_INFINITY;
|
|
109
|
+
const rowStart = row.StartedAt ? dayNumber(row.StartedAt) : Number.NEGATIVE_INFINITY;
|
|
110
|
+
return rowStart > bestStart ? row : best;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Walk the rungs and produce the due date.
|
|
115
|
+
*
|
|
116
|
+
* Never throws and never returns "unknown": every order gets a date or an explicit due-on-receipt,
|
|
117
|
+
* which is the order date itself.
|
|
118
|
+
*/
|
|
119
|
+
export function ResolveDueDate(input) {
|
|
120
|
+
// 1. Stated wins outright. Recomputing what somebody negotiated is the failure this rung exists
|
|
121
|
+
// to prevent, and it is also how a contracts app supplies an answer Orders cannot derive.
|
|
122
|
+
if (input.StatedDueDate) {
|
|
123
|
+
return {
|
|
124
|
+
DueDate: ToISODate(input.StatedDueDate),
|
|
125
|
+
PaymentTermsTypeID: input.StatedPaymentTermsTypeID ?? null,
|
|
126
|
+
Source: 'StatedDueDate',
|
|
127
|
+
WasStated: true,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// 2. Stated terms — the caller chose the terms and wants the date derived from them.
|
|
131
|
+
if (input.StatedPaymentTermsTypeID) {
|
|
132
|
+
const terms = input.TermsByID.get(input.StatedPaymentTermsTypeID.toLowerCase());
|
|
133
|
+
if (terms) {
|
|
134
|
+
return {
|
|
135
|
+
DueDate: AddDays(input.OrderDate, terms.NetDays ?? 0),
|
|
136
|
+
PaymentTermsTypeID: terms.PaymentTermsTypeID,
|
|
137
|
+
Source: 'StatedTerms',
|
|
138
|
+
WasStated: false,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
// Terms named but unresolvable: fall through rather than refuse. The id is still recorded on
|
|
142
|
+
// the order, so the mistake is visible without holding up the sale.
|
|
143
|
+
}
|
|
144
|
+
// 3. What this buyer negotiated.
|
|
145
|
+
const customer = BestCustomerTerms(input.CustomerTerms, input.OrderDate, input.CompanyID);
|
|
146
|
+
if (customer) {
|
|
147
|
+
return {
|
|
148
|
+
DueDate: AddDays(input.OrderDate, customer.NetDays ?? 0),
|
|
149
|
+
PaymentTermsTypeID: customer.PaymentTermsTypeID,
|
|
150
|
+
Source: 'CustomerTerms',
|
|
151
|
+
WasStated: false,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
// 4. What the selling company does by default.
|
|
155
|
+
if (input.CompanyDefault) {
|
|
156
|
+
return {
|
|
157
|
+
DueDate: AddDays(input.OrderDate, input.CompanyDefault.NetDays ?? 0),
|
|
158
|
+
PaymentTermsTypeID: input.CompanyDefault.PaymentTermsTypeID,
|
|
159
|
+
Source: 'CompanyDefault',
|
|
160
|
+
WasStated: false,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// 5. Due on receipt. An explicit answer rather than a null, so the collections worklist has
|
|
164
|
+
// something to age against on every order rather than silently skipping the unconfigured ones.
|
|
165
|
+
return {
|
|
166
|
+
DueDate: AddDays(input.OrderDate, 0),
|
|
167
|
+
PaymentTermsTypeID: null,
|
|
168
|
+
Source: 'DueOnReceipt',
|
|
169
|
+
WasStated: false,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=PaymentTermsBehavior.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PaymentTermsBehavior.js","sourceRoot":"","sources":["../src/PaymentTermsBehavior.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PaymentWebhookHandler — the unauthenticated door, and everything that makes it safe to open.
|
|
3
|
+
*
|
|
4
|
+
* THE ROUTE HAS NO AUTHENTICATION, by necessity (D19). Stripe will not present a bearer token; the
|
|
5
|
+
* signature IS the credential. Every guard below exists because of that.
|
|
6
|
+
*
|
|
7
|
+
* TRANSPORT-AGNOSTIC ON PURPOSE. This is a function over `(rawBody, headers)` returning a status and a
|
|
8
|
+
* body, not an Express handler. Two reasons. It is unit-testable without standing up a server, which
|
|
9
|
+
* matters because the interesting cases are a forged signature and a replayed event — neither of which
|
|
10
|
+
* you want to be exercising through HTTP. And the host application owns its middleware order: the route
|
|
11
|
+
* must be mounted BEFORE the auth middleware and with a raw-body parser, and only the bootstrap knows
|
|
12
|
+
* how to do that. `MountPaymentWebhook` at the bottom is the thin adapter.
|
|
13
|
+
*
|
|
14
|
+
* THE RAW BODY IS LOAD-BEARING. The signature covers the exact bytes sent. A JSON round-trip — key
|
|
15
|
+
* order, whitespace, unicode normalisation — invalidates it, so any parser that runs before this
|
|
16
|
+
* silently breaks every webhook. `express.raw({ type: 'application/json' })` on this path only.
|
|
17
|
+
*
|
|
18
|
+
* WHAT THE RESPONSE SAYS, AND WHY IT SAYS SO LITTLE. A 2xx means "we will not need this again". A
|
|
19
|
+
* gateway retries anything else, so:
|
|
20
|
+
*
|
|
21
|
+
* 200 applied, or ALREADY applied, or deliberately ignored — all three are settled
|
|
22
|
+
* 400 malformed or unverifiable — retrying will not help, so do not ask again
|
|
23
|
+
* 500 we failed to record a valid event — PLEASE retry, this one is ours
|
|
24
|
+
*
|
|
25
|
+
* The body carries no detail. A public endpoint that explains why a signature failed is an oracle for
|
|
26
|
+
* anyone probing it, and the reason belongs in our logs where it is useful and not in a response where
|
|
27
|
+
* it is a hint.
|
|
28
|
+
*
|
|
29
|
+
* CONNECTS TO:
|
|
30
|
+
* PURE: ./PaymentProviderBehavior.ts — signature, idempotency decision
|
|
31
|
+
* LOOKUP: ./PaymentProviderResolver.ts
|
|
32
|
+
* DOC: plans/archive/bizapps-orders-master.md D19
|
|
33
|
+
*/
|
|
34
|
+
import { IMetadataProvider, UserInfo } from '@memberjunction/core';
|
|
35
|
+
import { type WebhookAction } from './PaymentProviderBehavior.js';
|
|
36
|
+
export interface WebhookRequest {
|
|
37
|
+
/** The EXACT bytes received. See the header — a parsed-and-restringified body will not verify. */
|
|
38
|
+
RawBody: string;
|
|
39
|
+
Headers: Record<string, string | undefined>;
|
|
40
|
+
/** Which configured provider this endpoint is for, from the route path. */
|
|
41
|
+
PaymentProviderID: string;
|
|
42
|
+
}
|
|
43
|
+
export interface WebhookResponse {
|
|
44
|
+
Status: 200 | 400 | 500;
|
|
45
|
+
/** Deliberately terse — see the header. */
|
|
46
|
+
Body: {
|
|
47
|
+
received: boolean;
|
|
48
|
+
outcome?: WebhookAction;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Handle one delivery.
|
|
53
|
+
*
|
|
54
|
+
* The order of operations is the security model, and it is deliberate:
|
|
55
|
+
*
|
|
56
|
+
* 1. resolve the provider — so we know which secret to verify against
|
|
57
|
+
* 2. VERIFY THE SIGNATURE — before the payload is parsed, let alone trusted
|
|
58
|
+
* 3. parse — now that we know it came from the gateway
|
|
59
|
+
* 4. decide idempotently — before anything is written
|
|
60
|
+
* 5. apply — inside a transaction
|
|
61
|
+
*
|
|
62
|
+
* Nothing before step 2 reads the payload. Parsing first would mean acting on attacker-controlled JSON
|
|
63
|
+
* to decide whether to trust attacker-controlled JSON.
|
|
64
|
+
*/
|
|
65
|
+
export declare function HandlePaymentWebhook(request: WebhookRequest, provider: IMetadataProvider, user: UserInfo): Promise<WebhookResponse>;
|
|
66
|
+
/**
|
|
67
|
+
* Mount the route on an Express-shaped app.
|
|
68
|
+
*
|
|
69
|
+
* Typed structurally rather than against `express`, so this package takes no dependency on it — the
|
|
70
|
+
* host already has one, and this file should not be the reason a shared server package pulls in a web
|
|
71
|
+
* framework.
|
|
72
|
+
*
|
|
73
|
+
* TWO THINGS THE CALLER MUST GET RIGHT, and neither can be enforced from here:
|
|
74
|
+
*
|
|
75
|
+
* · Mount BEFORE the auth middleware. Stripe presents no token; auth would reject every delivery.
|
|
76
|
+
* · Give this path a RAW body parser and nothing else. `express.json()` anywhere upstream re-encodes
|
|
77
|
+
* the payload and every signature fails.
|
|
78
|
+
*
|
|
79
|
+
* ```ts
|
|
80
|
+
* app.post(
|
|
81
|
+
* '/webhooks/payments/:providerId',
|
|
82
|
+
* express.raw({ type: 'application/json' }),
|
|
83
|
+
* MountPaymentWebhook(() => ({ provider: Metadata.Provider, user: systemUser })),
|
|
84
|
+
* );
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export declare function MountPaymentWebhook(context: () => {
|
|
88
|
+
provider: IMetadataProvider;
|
|
89
|
+
user: UserInfo;
|
|
90
|
+
}): (req: WebhookHttpRequest, res: WebhookHttpResponse) => Promise<void>;
|
|
91
|
+
/** The Express request surface this route actually uses. Structural, so no dependency is needed. */
|
|
92
|
+
export interface WebhookHttpRequest {
|
|
93
|
+
body?: unknown;
|
|
94
|
+
headers?: Record<string, string | undefined>;
|
|
95
|
+
params?: Record<string, string | undefined>;
|
|
96
|
+
}
|
|
97
|
+
/** Likewise for the response. */
|
|
98
|
+
export interface WebhookHttpResponse {
|
|
99
|
+
status(code: number): {
|
|
100
|
+
json(body: unknown): void;
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=PaymentWebhookHandler.d.ts.map
|