@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.
Files changed (72) hide show
  1. package/dist/AccountingBridge.d.ts +6 -7
  2. package/dist/AccountingBridge.d.ts.map +1 -1
  3. package/dist/AccountingBridge.js +6 -7
  4. package/dist/AccountingBridge.js.map +1 -1
  5. package/dist/AdvanceOrderStateOperation.d.ts +5 -5
  6. package/dist/AdvanceOrderStateOperation.js +5 -5
  7. package/dist/BasePaymentProvider.d.ts +7 -0
  8. package/dist/BasePaymentProvider.d.ts.map +1 -1
  9. package/dist/BasePaymentProvider.js.map +1 -1
  10. package/dist/CheckoutPublishableKey.d.ts +69 -0
  11. package/dist/CheckoutPublishableKey.d.ts.map +1 -0
  12. package/dist/CheckoutPublishableKey.js +122 -0
  13. package/dist/CheckoutPublishableKey.js.map +1 -0
  14. package/dist/CheckoutSessionService.d.ts +54 -3
  15. package/dist/CheckoutSessionService.d.ts.map +1 -1
  16. package/dist/CheckoutSessionService.js +175 -41
  17. package/dist/CheckoutSessionService.js.map +1 -1
  18. package/dist/ConcessionApprovalTask.d.ts +4 -0
  19. package/dist/ConcessionApprovalTask.d.ts.map +1 -1
  20. package/dist/ConcessionApprovalTask.js +8 -8
  21. package/dist/ConcessionApprovalTask.js.map +1 -1
  22. package/dist/CreateOrderFromProviderPaymentOperation.d.ts.map +1 -1
  23. package/dist/CreateOrderFromProviderPaymentOperation.js +2 -1
  24. package/dist/CreateOrderFromProviderPaymentOperation.js.map +1 -1
  25. package/dist/OrderEntityServer.d.ts +26 -0
  26. package/dist/OrderEntityServer.d.ts.map +1 -1
  27. package/dist/OrderEntityServer.js +122 -8
  28. package/dist/OrderEntityServer.js.map +1 -1
  29. package/dist/OrderLineEntityServer.d.ts +13 -0
  30. package/dist/OrderLineEntityServer.d.ts.map +1 -1
  31. package/dist/OrderLineEntityServer.js +91 -0
  32. package/dist/OrderLineEntityServer.js.map +1 -1
  33. package/dist/OrderReview.d.ts +72 -0
  34. package/dist/OrderReview.d.ts.map +1 -0
  35. package/dist/OrderReview.js +611 -0
  36. package/dist/OrderReview.js.map +1 -0
  37. package/dist/OrdersSettings.d.ts +58 -0
  38. package/dist/OrdersSettings.d.ts.map +1 -1
  39. package/dist/OrdersSettings.js +111 -0
  40. package/dist/OrdersSettings.js.map +1 -1
  41. package/dist/PaymentProviderResolver.d.ts +10 -2
  42. package/dist/PaymentProviderResolver.d.ts.map +1 -1
  43. package/dist/PaymentProviderResolver.js +16 -5
  44. package/dist/PaymentProviderResolver.js.map +1 -1
  45. package/dist/PaymentWebhookHandler.d.ts.map +1 -1
  46. package/dist/PaymentWebhookHandler.js +2 -1
  47. package/dist/PaymentWebhookHandler.js.map +1 -1
  48. package/dist/ProductPriceEntityServer.d.ts +2 -2
  49. package/dist/ProductPriceEntityServer.d.ts.map +1 -1
  50. package/dist/ProductPriceEntityServer.js +3 -3
  51. package/dist/ProductPriceEntityServer.js.map +1 -1
  52. package/dist/RefundAuthority.d.ts +75 -0
  53. package/dist/RefundAuthority.d.ts.map +1 -0
  54. package/dist/RefundAuthority.js +155 -0
  55. package/dist/RefundAuthority.js.map +1 -0
  56. package/dist/RefundPaymentOperation.d.ts +2 -0
  57. package/dist/RefundPaymentOperation.d.ts.map +1 -1
  58. package/dist/RefundPaymentOperation.js +15 -0
  59. package/dist/RefundPaymentOperation.js.map +1 -1
  60. package/dist/SystemOrderOrigin.d.ts +40 -0
  61. package/dist/SystemOrderOrigin.d.ts.map +1 -0
  62. package/dist/SystemOrderOrigin.js +30 -0
  63. package/dist/SystemOrderOrigin.js.map +1 -0
  64. package/dist/checkoutCaptureAlert.d.ts +3 -0
  65. package/dist/checkoutCaptureAlert.d.ts.map +1 -1
  66. package/dist/checkoutCaptureAlert.js +21 -3
  67. package/dist/checkoutCaptureAlert.js.map +1 -1
  68. package/dist/index.d.ts +3 -1
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +3 -2
  71. package/dist/index.js.map +1 -1
  72. 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.sanitizeConfigurationForClient(configObj),
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.sanitizeConfigurationForClient(configObj),
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
- /** Marks expired-but-still-Open sessions Expired. Bounded; best-effort. */
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.Origin = 'Widget';
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
- const existingOrder = await md.GetEntityObject(ORDER_HEADER_ENTITY, contextUser);
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.Origin = 'Widget';
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', 'Checkout', contextUser);
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: 'Checkout' });
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: 'Checkout' });
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
- * No-ops when the intent is not a confirmed checkout session (back-office captures).
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
- if (!(await session.Load(sessionID)) || !session.DraftOrderID) {
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.