@mj-biz-apps/orders-core-entities-server 5.35.1 → 5.36.1
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/dist/AccountingBridge.d.ts +6 -7
- package/dist/AccountingBridge.d.ts.map +1 -1
- package/dist/AccountingBridge.js +6 -7
- package/dist/AccountingBridge.js.map +1 -1
- package/dist/AdvanceOrderStateOperation.d.ts +5 -5
- package/dist/AdvanceOrderStateOperation.js +5 -5
- package/dist/BasePaymentProvider.d.ts +7 -0
- package/dist/BasePaymentProvider.d.ts.map +1 -1
- package/dist/BasePaymentProvider.js.map +1 -1
- package/dist/CheckoutPublishableKey.d.ts +69 -0
- package/dist/CheckoutPublishableKey.d.ts.map +1 -0
- package/dist/CheckoutPublishableKey.js +122 -0
- package/dist/CheckoutPublishableKey.js.map +1 -0
- package/dist/CheckoutSessionService.d.ts +54 -3
- package/dist/CheckoutSessionService.d.ts.map +1 -1
- package/dist/CheckoutSessionService.js +175 -41
- package/dist/CheckoutSessionService.js.map +1 -1
- package/dist/ConcessionApprovalTask.d.ts +4 -0
- package/dist/ConcessionApprovalTask.d.ts.map +1 -1
- package/dist/ConcessionApprovalTask.js +8 -8
- package/dist/ConcessionApprovalTask.js.map +1 -1
- package/dist/CreateOrderFromProviderPaymentOperation.d.ts.map +1 -1
- package/dist/CreateOrderFromProviderPaymentOperation.js +2 -1
- package/dist/CreateOrderFromProviderPaymentOperation.js.map +1 -1
- package/dist/OrderEntityServer.d.ts +26 -0
- package/dist/OrderEntityServer.d.ts.map +1 -1
- package/dist/OrderEntityServer.js +122 -8
- package/dist/OrderEntityServer.js.map +1 -1
- package/dist/OrderLineEntityServer.d.ts +13 -0
- package/dist/OrderLineEntityServer.d.ts.map +1 -1
- package/dist/OrderLineEntityServer.js +91 -0
- package/dist/OrderLineEntityServer.js.map +1 -1
- package/dist/OrderReview.d.ts +72 -0
- package/dist/OrderReview.d.ts.map +1 -0
- package/dist/OrderReview.js +611 -0
- package/dist/OrderReview.js.map +1 -0
- package/dist/OrdersSettings.d.ts +58 -0
- package/dist/OrdersSettings.d.ts.map +1 -1
- package/dist/OrdersSettings.js +111 -0
- package/dist/OrdersSettings.js.map +1 -1
- package/dist/PaymentProviderResolver.d.ts +10 -2
- package/dist/PaymentProviderResolver.d.ts.map +1 -1
- package/dist/PaymentProviderResolver.js +16 -5
- package/dist/PaymentProviderResolver.js.map +1 -1
- package/dist/PaymentWebhookHandler.d.ts.map +1 -1
- package/dist/PaymentWebhookHandler.js +2 -1
- package/dist/PaymentWebhookHandler.js.map +1 -1
- package/dist/ProductPriceEntityServer.d.ts +2 -2
- package/dist/ProductPriceEntityServer.d.ts.map +1 -1
- package/dist/ProductPriceEntityServer.js +3 -3
- package/dist/ProductPriceEntityServer.js.map +1 -1
- package/dist/RefundAuthority.d.ts +75 -0
- package/dist/RefundAuthority.d.ts.map +1 -0
- package/dist/RefundAuthority.js +155 -0
- package/dist/RefundAuthority.js.map +1 -0
- package/dist/RefundPaymentOperation.d.ts +2 -0
- package/dist/RefundPaymentOperation.d.ts.map +1 -1
- package/dist/RefundPaymentOperation.js +15 -0
- package/dist/RefundPaymentOperation.js.map +1 -1
- package/dist/SystemOrderOrigin.d.ts +40 -0
- package/dist/SystemOrderOrigin.d.ts.map +1 -0
- package/dist/SystemOrderOrigin.js +30 -0
- package/dist/SystemOrderOrigin.js.map +1 -0
- package/dist/checkoutCaptureAlert.d.ts +3 -0
- package/dist/checkoutCaptureAlert.d.ts.map +1 -1
- package/dist/checkoutCaptureAlert.js +21 -3
- package/dist/checkoutCaptureAlert.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/package.json +22 -22
|
@@ -18,6 +18,7 @@ import { ExplainMissingCheckoutAttendees, LoadProductsRequiringAttendees } from
|
|
|
18
18
|
import { ResolvePersonByEmail } from './PersonByEmail.js';
|
|
19
19
|
import { OpenPaymentIntent, SUPPORTED_PAYMENT_CURRENCY } from './PaymentIntentService.js';
|
|
20
20
|
import { ResolvePaymentProvider } from './PaymentProviderResolver.js';
|
|
21
|
+
import { ResolveCheckoutPublishableKey } from './CheckoutPublishableKey.js';
|
|
21
22
|
import { FindReusableProviderCustomerRef, SnapshotSellsSubscription, SaveCheckoutInstrumentForRenewals, } from './CheckoutSavedInstrument.js';
|
|
22
23
|
import { CheckoutMemberDiscountNotConfiguredError, DEFAULT_TYPED_CODE_PRECEDENCE, IsRegisteredMemberPromotionCode, ResolveCheckoutMemberDiscountResolver, } from './CheckoutMemberDiscountResolver.js';
|
|
23
24
|
import { CapturePaymentOperation } from './CapturePaymentOperation.js';
|
|
@@ -26,6 +27,7 @@ import { raiseCheckoutCaptureTerminalAlert, raiseCheckoutSettledNotBookedAlert }
|
|
|
26
27
|
import { CheckoutStepLog } from './CheckoutStepLog.js';
|
|
27
28
|
import { isCaptureRefusalRetryable, isTerminalCapturePrecheck, } from './checkoutCaptureRetry.js';
|
|
28
29
|
import { DescribeCheckoutSnapshot, SendOrderDescriptionToGateway } from './IntentDescription.js';
|
|
30
|
+
import { SetSystemOrderOrigin } from './SystemOrderOrigin.js';
|
|
29
31
|
import { LoadRenewalPaymentOrder, PayerOwnsRenewalCustomer, RenewalPaymentAlreadyConfirmed, RenewalPaymentLinkSecret, RenewalPaymentRefusal, RenewalPaymentTokenMessage, VerifyRenewalPaymentToken, } from './CheckoutRenewalPayment.js';
|
|
30
32
|
const CHECKOUT_WIDGET_ENTITY = 'MJ_BizApps_Orders: Checkout Widgets';
|
|
31
33
|
const CHECKOUT_DISTRIBUTION_ENTITY = 'MJ_BizApps_Orders: Checkout Widget Distributions';
|
|
@@ -133,6 +135,13 @@ function SettledButShortMessage(cause) {
|
|
|
133
135
|
return `${what}, so your payment does not cover the total and the order was not placed. `
|
|
134
136
|
+ 'Our team has been notified and will arrange a refund.';
|
|
135
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* How long a caller that finds a session Processing waits for the other completion to finish. The
|
|
140
|
+
* browser's complete call and the payment webhook can both arrive for one payment (#724); the one
|
|
141
|
+
* that loses the latch waits for the winner and answers with its order.
|
|
142
|
+
*/
|
|
143
|
+
const PROCESSING_WAIT_MS = 30_000;
|
|
144
|
+
const PROCESSING_POLL_MS = 1_000;
|
|
136
145
|
export class CheckoutSessionService {
|
|
137
146
|
/**
|
|
138
147
|
* Resolves a public distribution slug, validates widget status, and mints an active CheckoutSession.
|
|
@@ -221,7 +230,7 @@ export class CheckoutSessionService {
|
|
|
221
230
|
WidgetID: widget.ID,
|
|
222
231
|
WidgetName: widget.Name,
|
|
223
232
|
CompanyID: widget.CompanyID,
|
|
224
|
-
Configuration: this.
|
|
233
|
+
Configuration: await this.clientConfiguration(configObj, widget.Name, contextUser),
|
|
225
234
|
CustomCSS: configObj.customUI.css || widget.CustomCSS,
|
|
226
235
|
CustomJS: configObj.customUI.js || widget.CustomJS,
|
|
227
236
|
ExpiresAt: session.ExpiresAt.toISOString()
|
|
@@ -316,7 +325,7 @@ export class CheckoutSessionService {
|
|
|
316
325
|
WidgetID: widget.ID,
|
|
317
326
|
WidgetName: widget.Name,
|
|
318
327
|
CompanyID: widget.CompanyID,
|
|
319
|
-
Configuration: this.
|
|
328
|
+
Configuration: await this.clientConfiguration(configObj, widget.Name, contextUser),
|
|
320
329
|
CustomCSS: configObj.customUI.css || widget.CustomCSS,
|
|
321
330
|
CustomJS: configObj.customUI.js || widget.CustomJS,
|
|
322
331
|
ExpiresAt: session.ExpiresAt.toISOString(),
|
|
@@ -354,7 +363,8 @@ export class CheckoutSessionService {
|
|
|
354
363
|
* anonymous caller. `Configuration` is admin-authored JSON with an open index signature,
|
|
355
364
|
* so a future secret-shaped key (a webhook secret, an API credential, a server-side
|
|
356
365
|
* payment provider ref) must never ride along to the browser. `stripePublishableKey`
|
|
357
|
-
* is publishable by definition and survives
|
|
366
|
+
* is publishable by definition and survives — but its VALUE is decided by
|
|
367
|
+
* `ResolveCheckoutPublishableKey` (see `clientConfiguration`), not taken from here as is.
|
|
358
368
|
*/
|
|
359
369
|
/**
|
|
360
370
|
* Keys that may cross to an unauthenticated browser. Anything else in the
|
|
@@ -387,6 +397,24 @@ export class CheckoutSessionService {
|
|
|
387
397
|
'questions',
|
|
388
398
|
'choiceGroups',
|
|
389
399
|
]); }
|
|
400
|
+
/**
|
|
401
|
+
* The Configuration an anonymous browser receives: the allowlisted keys, with
|
|
402
|
+
* `stripePublishableKey` replaced by the resolved key — the payment provider's
|
|
403
|
+
* `<CredentialsRef>_PUBLISHABLE_KEY` when the server has one, else the widget's own value.
|
|
404
|
+
* Every path that hands a browser a widget Configuration goes through here, so the session API
|
|
405
|
+
* and the server-rendered host page cannot disagree about which key to load Stripe.js with.
|
|
406
|
+
*/
|
|
407
|
+
static async clientConfiguration(configObj, widgetName, contextUser) {
|
|
408
|
+
const client = this.sanitizeConfigurationForClient(configObj);
|
|
409
|
+
const publishable = await ResolveCheckoutPublishableKey(configObj, widgetName, contextUser);
|
|
410
|
+
if (publishable.Key) {
|
|
411
|
+
client.stripePublishableKey = publishable.Key;
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
delete client.stripePublishableKey;
|
|
415
|
+
}
|
|
416
|
+
return client;
|
|
417
|
+
}
|
|
390
418
|
static sanitizeConfigurationForClient(configObj) {
|
|
391
419
|
const sanitized = {};
|
|
392
420
|
for (const [key, value] of Object.entries(configObj)) {
|
|
@@ -429,7 +457,12 @@ export class CheckoutSessionService {
|
|
|
429
457
|
}
|
|
430
458
|
return allowed;
|
|
431
459
|
}
|
|
432
|
-
/**
|
|
460
|
+
/**
|
|
461
|
+
* Marks expired-but-still-Open sessions Expired. Bounded; best-effort.
|
|
462
|
+
*
|
|
463
|
+
* A session whose payment settled has charged the buyer with no order, so expiring it raises the
|
|
464
|
+
* "settled with no order" alert: otherwise it would leave nothing for staff to see (#724).
|
|
465
|
+
*/
|
|
433
466
|
static async ReapExpiredOpenSessions(contextUser, limit = 50) {
|
|
434
467
|
const rv = new RunView();
|
|
435
468
|
const nowUtc = new Date().toISOString();
|
|
@@ -457,6 +490,9 @@ export class CheckoutSessionService {
|
|
|
457
490
|
session.Status = 'Expired';
|
|
458
491
|
if (await session.Save()) {
|
|
459
492
|
n++;
|
|
493
|
+
if (await this.sessionPaymentSettled(session, md, contextUser)) {
|
|
494
|
+
void raiseCheckoutSettledNotBookedAlert(session.ID, session.PaymentIntentID, 'The checkout expired after its payment settled, and no order was created.', contextUser);
|
|
495
|
+
}
|
|
460
496
|
}
|
|
461
497
|
}
|
|
462
498
|
catch (err) {
|
|
@@ -465,6 +501,27 @@ export class CheckoutSessionService {
|
|
|
465
501
|
}
|
|
466
502
|
return n;
|
|
467
503
|
}
|
|
504
|
+
/** True when the session's payment intent is recorded as settled. Reads our row, not the gateway. */
|
|
505
|
+
static async sessionPaymentSettled(session, md, contextUser) {
|
|
506
|
+
if (!session.PaymentIntentID) {
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
const intent = await md.GetEntityObject(PAYMENT_INTENT_ENTITY, contextUser);
|
|
510
|
+
return (await intent.Load(session.PaymentIntentID)) && SETTLED_INTENT_STATUSES.includes(intent.Status);
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Waits, up to `PROCESSING_WAIT_MS`, for a session another caller latched to Processing to leave
|
|
514
|
+
* that state, reloading it in place. Returns at once when it is not Processing.
|
|
515
|
+
*/
|
|
516
|
+
static async awaitProcessingFinished(session) {
|
|
517
|
+
const deadline = Date.now() + PROCESSING_WAIT_MS;
|
|
518
|
+
while (session.Status === 'Processing' && Date.now() < deadline) {
|
|
519
|
+
await new Promise((resolve) => setTimeout(resolve, PROCESSING_POLL_MS));
|
|
520
|
+
if (!(await session.Load(session.ID))) {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
468
525
|
/**
|
|
469
526
|
* Verifies a caller-presented client session key against the session row's stored key.
|
|
470
527
|
* The session GUID alone must never be sufficient to drive a session — both values are
|
|
@@ -1078,7 +1135,7 @@ export class CheckoutSessionService {
|
|
|
1078
1135
|
order.NewRecord();
|
|
1079
1136
|
order.CompanyID = widget.CompanyID;
|
|
1080
1137
|
order.Status = 'Draft';
|
|
1081
|
-
order
|
|
1138
|
+
SetSystemOrderOrigin(order, 'Widget');
|
|
1082
1139
|
order.SourceCheckoutWidgetID = widget.ID;
|
|
1083
1140
|
// Defence-in-depth: the server pre-warms this engine at startup (@RegisterForStartup),
|
|
1084
1141
|
// but that pre-warm only reaches modules already evaluated when Startup() runs, which
|
|
@@ -1763,27 +1820,13 @@ export class CheckoutSessionService {
|
|
|
1763
1820
|
Status: session.Status
|
|
1764
1821
|
};
|
|
1765
1822
|
}
|
|
1823
|
+
// The payment webhook may be completing this session right now (#724): answer with its order.
|
|
1824
|
+
await this.awaitProcessingFinished(session);
|
|
1766
1825
|
// Replay safety: a session that already completed returns its existing order rather
|
|
1767
1826
|
// than an error — a network retry after a successful booking must never double-book
|
|
1768
1827
|
// and must never strand the buyer without their order id.
|
|
1769
1828
|
if (session.Status === 'Confirmed' && session.DraftOrderID) {
|
|
1770
|
-
|
|
1771
|
-
const orderLoaded = await existingOrder.Load(session.DraftOrderID);
|
|
1772
|
-
// A first complete can confirm the order and then fail to book the payment.
|
|
1773
|
-
// Replay is one retry: CapturePayment is idempotent on `checkout-complete:${session.ID}`.
|
|
1774
|
-
// `payment_intent.succeeded` is the other — BookSettledCheckoutPaymentIfNeeded, including
|
|
1775
|
-
// on AlreadyApplied webhook deliveries so a 500 after applyEvent still converges.
|
|
1776
|
-
if (orderLoaded) {
|
|
1777
|
-
await this.applySettledPaymentToOrder(session, existingOrder, contextUser, { Source: 'Checkout' });
|
|
1778
|
-
}
|
|
1779
|
-
return {
|
|
1780
|
-
Success: true,
|
|
1781
|
-
SessionID: sessionID,
|
|
1782
|
-
Status: 'Confirmed',
|
|
1783
|
-
OrderID: session.DraftOrderID,
|
|
1784
|
-
OrderNumber: orderLoaded ? (existingOrder.OrderNumber || session.DraftOrderID) : session.DraftOrderID,
|
|
1785
|
-
TotalGross: orderLoaded ? (existingOrder.TotalGross ?? undefined) : undefined
|
|
1786
|
-
};
|
|
1829
|
+
return this.replayConfirmedSession(session, md, contextUser, { Source: 'Checkout', Report: {} });
|
|
1787
1830
|
}
|
|
1788
1831
|
if (session.Status !== 'Open') {
|
|
1789
1832
|
return {
|
|
@@ -1801,12 +1844,77 @@ export class CheckoutSessionService {
|
|
|
1801
1844
|
Status: 'Expired'
|
|
1802
1845
|
};
|
|
1803
1846
|
}
|
|
1847
|
+
return this.completeOpenSession(session, md, contextUser, { Source: 'Checkout', Evidence: evidence, Report: {} });
|
|
1848
|
+
}
|
|
1849
|
+
/**
|
|
1850
|
+
* Completes a session paid for at the gateway whose browser never asked (#724): the buyer closed
|
|
1851
|
+
* the tab, or lost the page, after the payment succeeded. Reached from the payment webhook, so there
|
|
1852
|
+
* is no client session key: the signature-verified event is the proof, and the completion checks
|
|
1853
|
+
* the intent's state and amount against the re-priced order exactly as the browser's call does.
|
|
1854
|
+
*
|
|
1855
|
+
* The session expiry is not enforced: the buyer paid inside it, and expiry stops a session from
|
|
1856
|
+
* being driven further, not a paid one from being booked.
|
|
1857
|
+
*
|
|
1858
|
+
* Any refusal raises the "settled with no order" alert, since nobody is on the page to see it.
|
|
1859
|
+
*/
|
|
1860
|
+
static async completeFromWebhook(session, md, contextUser) {
|
|
1861
|
+
const trigger = { Source: 'Webhook', Report: {} };
|
|
1862
|
+
const result = await this.completeOpenSession(session, md, contextUser, trigger);
|
|
1863
|
+
if (result.Success) {
|
|
1864
|
+
return trigger.Report.Capture ?? { Attempted: true, Booked: true };
|
|
1865
|
+
}
|
|
1866
|
+
const reason = result.ErrorMessage ?? 'the checkout could not be completed';
|
|
1867
|
+
if (trigger.Report.Retryable) {
|
|
1868
|
+
return { Attempted: true, Booked: false, Retryable: true, ErrorMessage: reason };
|
|
1869
|
+
}
|
|
1870
|
+
if (!trigger.Report.Alerted) {
|
|
1871
|
+
void raiseCheckoutSettledNotBookedAlert(session.ID, session.PaymentIntentID, reason, contextUser);
|
|
1872
|
+
}
|
|
1873
|
+
return { Attempted: true, Booked: false, Retryable: false, ErrorMessage: reason };
|
|
1874
|
+
}
|
|
1875
|
+
/**
|
|
1876
|
+
* The existing order of a Confirmed session, booking its payment if the first completion did
|
|
1877
|
+
* not. CapturePayment is idempotent on `checkout-complete:${session.ID}`, so this is one of the
|
|
1878
|
+
* retries; `payment_intent.succeeded` is the other (BookSettledCheckoutPaymentIfNeeded, including
|
|
1879
|
+
* on AlreadyApplied webhook deliveries so a 500 after applyEvent still converges).
|
|
1880
|
+
*/
|
|
1881
|
+
static async replayConfirmedSession(session, md, contextUser, trigger) {
|
|
1882
|
+
const orderID = session.DraftOrderID ?? '';
|
|
1883
|
+
const existingOrder = await md.GetEntityObject(ORDER_HEADER_ENTITY, contextUser);
|
|
1884
|
+
const orderLoaded = await existingOrder.Load(orderID);
|
|
1885
|
+
if (orderLoaded) {
|
|
1886
|
+
trigger.Report.Capture = await this.applySettledPaymentToOrder(session, existingOrder, contextUser, { Source: trigger.Source });
|
|
1887
|
+
}
|
|
1888
|
+
return {
|
|
1889
|
+
Success: true,
|
|
1890
|
+
SessionID: session.ID,
|
|
1891
|
+
Status: 'Confirmed',
|
|
1892
|
+
OrderID: orderID,
|
|
1893
|
+
OrderNumber: orderLoaded ? (existingOrder.OrderNumber || orderID) : orderID,
|
|
1894
|
+
TotalGross: orderLoaded ? (existingOrder.TotalGross ?? undefined) : undefined
|
|
1895
|
+
};
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1898
|
+
* Builds and confirms the order for an Open session — the work behind both the browser's
|
|
1899
|
+
* complete call and the payment webhook. The caller has checked the session may be completed.
|
|
1900
|
+
*/
|
|
1901
|
+
static async completeOpenSession(session, md, contextUser, trigger) {
|
|
1902
|
+
const sessionID = session.ID;
|
|
1903
|
+
const evidence = trigger.Evidence;
|
|
1804
1904
|
if (session.DraftOrderID) {
|
|
1805
|
-
return this.completeRenewalPayment(session, md, contextUser);
|
|
1905
|
+
return this.completeRenewalPayment(session, md, contextUser, trigger);
|
|
1806
1906
|
}
|
|
1807
1907
|
// Atomically latch status to Processing to prevent concurrent duplicate checkout processing
|
|
1808
1908
|
const latched = await this.latchSessionProcessingAtomic(sessionID, md, contextUser);
|
|
1809
1909
|
if (!latched) {
|
|
1910
|
+
// The other caller for this payment (browser or webhook) holds the latch: wait for it.
|
|
1911
|
+
if (await session.Load(sessionID)) {
|
|
1912
|
+
await this.awaitProcessingFinished(session);
|
|
1913
|
+
if (session.Status === 'Confirmed' && session.DraftOrderID) {
|
|
1914
|
+
return this.replayConfirmedSession(session, md, contextUser, trigger);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
trigger.Report.Retryable = true;
|
|
1810
1918
|
return {
|
|
1811
1919
|
Success: false,
|
|
1812
1920
|
ErrorMessage: 'Session is not in an Open status or was concurrently processed',
|
|
@@ -1899,7 +2007,7 @@ export class CheckoutSessionService {
|
|
|
1899
2007
|
order.CompanyID = widget.CompanyID;
|
|
1900
2008
|
order.BillToPersonID = session.PersonID ?? null;
|
|
1901
2009
|
order.ShipToPersonID = session.PersonID ?? null;
|
|
1902
|
-
order
|
|
2010
|
+
SetSystemOrderOrigin(order, 'Widget');
|
|
1903
2011
|
order.OrderType = 'Sale';
|
|
1904
2012
|
order.SourceCheckoutWidgetID = widget.ID;
|
|
1905
2013
|
// VAT location evidence beside the billing address (#480). Null when the edge has no
|
|
@@ -2105,6 +2213,7 @@ export class CheckoutSessionService {
|
|
|
2105
2213
|
? `Promotion code ${typedCodeDeclined.Code} was declined on re-price (${typedCodeDeclined.Reason}). ${paymentFailure.Message}`
|
|
2106
2214
|
: paymentFailure.Message;
|
|
2107
2215
|
void raiseCheckoutSettledNotBookedAlert(sessionID, session.PaymentIntentID, reason, contextUser);
|
|
2216
|
+
trigger.Report.Alerted = true;
|
|
2108
2217
|
}
|
|
2109
2218
|
return {
|
|
2110
2219
|
Success: false,
|
|
@@ -2120,7 +2229,7 @@ export class CheckoutSessionService {
|
|
|
2120
2229
|
order.CardIssuingCountry = paymentCheck.CardIssuingCountry;
|
|
2121
2230
|
// Payment has checked out, so from here a failure leaves a paid checkout without
|
|
2122
2231
|
// an order. Record the attempt; Confirm rolls back on failure, the record does not.
|
|
2123
|
-
confirmStep = await CheckoutStepLog.Begin(sessionID, 'Confirm',
|
|
2232
|
+
confirmStep = await CheckoutStepLog.Begin(sessionID, 'Confirm', trigger.Source, contextUser);
|
|
2124
2233
|
}
|
|
2125
2234
|
// Payment has checked out (or none was due): create the Persons the checkout needs.
|
|
2126
2235
|
if (newPayerFields) {
|
|
@@ -2163,7 +2272,7 @@ export class CheckoutSessionService {
|
|
|
2163
2272
|
}
|
|
2164
2273
|
// Book the PaymentHeader + allocation now that the order exists. retrieve-on-complete
|
|
2165
2274
|
// only stamps PaymentIntent.Status = Succeeded; AmountPaid stays 0 until CapturePayment.
|
|
2166
|
-
await this.applySettledPaymentToOrder(session, order, contextUser, { Source:
|
|
2275
|
+
trigger.Report.Capture = await this.applySettledPaymentToOrder(session, order, contextUser, { Source: trigger.Source });
|
|
2167
2276
|
// Mint the GuestOrder identity claim for the buyer's email so a later account
|
|
2168
2277
|
// (MJ core's IdentityClaimEngineServer) can attach the order + its entitlement
|
|
2169
2278
|
// grants on redemption or claim-on-login. Best-effort: a claim failure must never
|
|
@@ -2220,6 +2329,8 @@ export class CheckoutSessionService {
|
|
|
2220
2329
|
}
|
|
2221
2330
|
await CheckoutSessionService.revertSessionOpenAtomic(sessionID, md, contextUser);
|
|
2222
2331
|
session.Status = 'Open';
|
|
2332
|
+
// A thrown save or read may pass on a later attempt; a refusal above would not.
|
|
2333
|
+
trigger.Report.Retryable = true;
|
|
2223
2334
|
LogError(`[CheckoutSessionService] CompleteCheckout failed for session ${sessionID}: ${msg}`);
|
|
2224
2335
|
return {
|
|
2225
2336
|
Success: false,
|
|
@@ -2284,7 +2395,7 @@ export class CheckoutSessionService {
|
|
|
2284
2395
|
* booked through the ordinary checkout capture, which also replaces the renewal card. No order is
|
|
2285
2396
|
* created or confirmed here — the renewal order already is.
|
|
2286
2397
|
*/
|
|
2287
|
-
static async completeRenewalPayment(session, md, contextUser) {
|
|
2398
|
+
static async completeRenewalPayment(session, md, contextUser, trigger) {
|
|
2288
2399
|
const refuse = (message, status = session.Status) => ({
|
|
2289
2400
|
Success: false,
|
|
2290
2401
|
ErrorMessage: message,
|
|
@@ -2303,16 +2414,18 @@ export class CheckoutSessionService {
|
|
|
2303
2414
|
return refuse(paymentProblem.Message);
|
|
2304
2415
|
}
|
|
2305
2416
|
if (!(await this.latchSessionProcessingAtomic(session.ID, md, contextUser))) {
|
|
2417
|
+
trigger.Report.Retryable = true;
|
|
2306
2418
|
return refuse('Session is not in an Open status or was concurrently processed', 'Unknown');
|
|
2307
2419
|
}
|
|
2308
2420
|
if (!(await this.stampSessionConfirmedAtomic(session.ID, renewal.OrderID, md, contextUser))) {
|
|
2309
2421
|
await this.revertSessionOpenAtomic(session.ID, md, contextUser);
|
|
2422
|
+
trigger.Report.Retryable = true;
|
|
2310
2423
|
return refuse('The renewal payment could not be recorded. Please try again.', 'Open');
|
|
2311
2424
|
}
|
|
2312
2425
|
session.Status = 'Confirmed';
|
|
2313
2426
|
const order = await md.GetEntityObject(ORDER_HEADER_ENTITY, contextUser);
|
|
2314
2427
|
if (await order.Load(renewal.OrderID)) {
|
|
2315
|
-
await this.applySettledPaymentToOrder(session, order, contextUser, { Source:
|
|
2428
|
+
trigger.Report.Capture = await this.applySettledPaymentToOrder(session, order, contextUser, { Source: trigger.Source });
|
|
2316
2429
|
}
|
|
2317
2430
|
return {
|
|
2318
2431
|
Success: true,
|
|
@@ -2412,26 +2525,36 @@ export class CheckoutSessionService {
|
|
|
2412
2525
|
* 2. This method, from `payment_intent.succeeded` — including AlreadyApplied deliveries,
|
|
2413
2526
|
* so a 500 after the intent row is stamped still converges on the next Stripe retry.
|
|
2414
2527
|
*
|
|
2415
|
-
*
|
|
2528
|
+
* A session the payment settled for that is still Open was never completed: the buyer's page
|
|
2529
|
+
* closed before it asked (#724). This method completes it, as the browser would have. One that
|
|
2530
|
+
* expired first is past completing, and raises the "settled with no order" alert.
|
|
2531
|
+
*
|
|
2532
|
+
* No-ops when no checkout session owns the intent (back-office captures).
|
|
2416
2533
|
*/
|
|
2417
2534
|
static async BookSettledCheckoutPaymentIfNeeded(paymentIntentID, contextUser) {
|
|
2418
2535
|
if (!paymentIntentID) {
|
|
2419
2536
|
return { Attempted: false, Booked: false };
|
|
2420
2537
|
}
|
|
2421
|
-
const rv = new RunView();
|
|
2422
|
-
const found = await rv.RunView({
|
|
2423
|
-
EntityName: CHECKOUT_SESSION_ENTITY,
|
|
2424
|
-
ExtraFilter: `PaymentIntentID = '${EscapeText(paymentIntentID)}' AND Status = 'Confirmed'`,
|
|
2425
|
-
MaxRows: 1,
|
|
2426
|
-
ResultType: 'simple',
|
|
2427
|
-
}, contextUser);
|
|
2428
|
-
const sessionID = found?.Results?.[0]?.ID;
|
|
2429
|
-
if (!sessionID) {
|
|
2430
|
-
return { Attempted: false, Booked: false };
|
|
2431
|
-
}
|
|
2432
2538
|
const md = new Metadata();
|
|
2433
2539
|
const session = await md.GetEntityObject(CHECKOUT_SESSION_ENTITY, contextUser);
|
|
2434
|
-
|
|
2540
|
+
const sessionID = (await this.sessionIDForIntent(paymentIntentID, `Status = 'Confirmed'`, contextUser)) ??
|
|
2541
|
+
(await this.sessionIDForIntent(paymentIntentID, `Status IN ('Open', 'Processing', 'Expired')`, contextUser));
|
|
2542
|
+
if (!sessionID || !(await session.Load(sessionID))) {
|
|
2543
|
+
return { Attempted: false, Booked: false };
|
|
2544
|
+
}
|
|
2545
|
+
await this.awaitProcessingFinished(session);
|
|
2546
|
+
if (session.Status === 'Open') {
|
|
2547
|
+
return this.completeFromWebhook(session, md, contextUser);
|
|
2548
|
+
}
|
|
2549
|
+
if (session.Status === 'Processing') {
|
|
2550
|
+
return { Attempted: true, Booked: false, Retryable: true, ErrorMessage: 'the checkout is still being completed' };
|
|
2551
|
+
}
|
|
2552
|
+
if (session.Status === 'Expired') {
|
|
2553
|
+
const reason = 'The checkout expired before it was completed, so its settled payment has no order.';
|
|
2554
|
+
void raiseCheckoutSettledNotBookedAlert(session.ID, session.PaymentIntentID, reason, contextUser);
|
|
2555
|
+
return { Attempted: true, Booked: false, Retryable: false, ErrorMessage: reason };
|
|
2556
|
+
}
|
|
2557
|
+
if (session.Status !== 'Confirmed' || !session.DraftOrderID) {
|
|
2435
2558
|
return { Attempted: false, Booked: false };
|
|
2436
2559
|
}
|
|
2437
2560
|
const order = await md.GetEntityObject(ORDER_HEADER_ENTITY, contextUser);
|
|
@@ -2440,6 +2563,17 @@ export class CheckoutSessionService {
|
|
|
2440
2563
|
}
|
|
2441
2564
|
return this.applySettledPaymentToOrder(session, order, contextUser, { Source: 'Webhook' });
|
|
2442
2565
|
}
|
|
2566
|
+
/** The checkout session that opened this payment intent, among those `statusFilter` admits. */
|
|
2567
|
+
static async sessionIDForIntent(paymentIntentID, statusFilter, contextUser) {
|
|
2568
|
+
const rv = new RunView();
|
|
2569
|
+
const found = await rv.RunView({
|
|
2570
|
+
EntityName: CHECKOUT_SESSION_ENTITY,
|
|
2571
|
+
ExtraFilter: `PaymentIntentID = '${EscapeText(paymentIntentID)}' AND ${statusFilter}`,
|
|
2572
|
+
MaxRows: 1,
|
|
2573
|
+
ResultType: 'simple',
|
|
2574
|
+
}, contextUser);
|
|
2575
|
+
return found?.Results?.[0]?.ID;
|
|
2576
|
+
}
|
|
2443
2577
|
/**
|
|
2444
2578
|
* Operator replay of the Capture step (`Orders.ReplayCheckoutStep`, #326). The same idempotent
|
|
2445
2579
|
* CapturePayment as the complete call and the webhook, recorded as a Replay by the operator.
|