@mj-biz-apps/orders-core-entities-server 5.1.0 → 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.
Files changed (99) hide show
  1. package/README.md +3 -1
  2. package/dist/AdvanceOrderStateOperation.d.ts.map +1 -1
  3. package/dist/AdvanceOrderStateOperation.js +35 -50
  4. package/dist/AdvanceOrderStateOperation.js.map +1 -1
  5. package/dist/BasePaymentProvider.d.ts +24 -0
  6. package/dist/BasePaymentProvider.d.ts.map +1 -1
  7. package/dist/BasePaymentProvider.js +10 -0
  8. package/dist/BasePaymentProvider.js.map +1 -1
  9. package/dist/CancelSubscriptionOperation.d.ts +9 -1
  10. package/dist/CancelSubscriptionOperation.d.ts.map +1 -1
  11. package/dist/CancelSubscriptionOperation.js +15 -3
  12. package/dist/CancelSubscriptionOperation.js.map +1 -1
  13. package/dist/CheckEntitlementOperation.d.ts +27 -0
  14. package/dist/CheckEntitlementOperation.d.ts.map +1 -0
  15. package/dist/CheckEntitlementOperation.js +45 -0
  16. package/dist/CheckEntitlementOperation.js.map +1 -0
  17. package/dist/CheckoutSessionService.d.ts +258 -0
  18. package/dist/CheckoutSessionService.d.ts.map +1 -0
  19. package/dist/CheckoutSessionService.js +1557 -0
  20. package/dist/CheckoutSessionService.js.map +1 -0
  21. package/dist/EntitlementBehavior.d.ts +68 -0
  22. package/dist/EntitlementBehavior.d.ts.map +1 -1
  23. package/dist/EntitlementBehavior.js +126 -0
  24. package/dist/EntitlementBehavior.js.map +1 -1
  25. package/dist/EntitlementEngine.d.ts +11 -0
  26. package/dist/EntitlementEngine.d.ts.map +1 -1
  27. package/dist/EntitlementEngine.js +38 -1
  28. package/dist/EntitlementEngine.js.map +1 -1
  29. package/dist/EntitlementGrantClaimDriver.d.ts +42 -0
  30. package/dist/EntitlementGrantClaimDriver.d.ts.map +1 -0
  31. package/dist/EntitlementGrantClaimDriver.js +158 -0
  32. package/dist/EntitlementGrantClaimDriver.js.map +1 -0
  33. package/dist/EntitlementRead.d.ts +82 -0
  34. package/dist/EntitlementRead.d.ts.map +1 -0
  35. package/dist/EntitlementRead.js +368 -0
  36. package/dist/EntitlementRead.js.map +1 -0
  37. package/dist/FulfillOrderLinesOperation.d.ts.map +1 -1
  38. package/dist/FulfillOrderLinesOperation.js +6 -12
  39. package/dist/FulfillOrderLinesOperation.js.map +1 -1
  40. package/dist/FulfillmentBehavior.js +2 -2
  41. package/dist/FulfillmentBehavior.js.map +1 -1
  42. package/dist/GetFulfillmentQueueOperation.d.ts.map +1 -1
  43. package/dist/GetFulfillmentQueueOperation.js +2 -4
  44. package/dist/GetFulfillmentQueueOperation.js.map +1 -1
  45. package/dist/GuestOrderClaimDriver.d.ts +36 -0
  46. package/dist/GuestOrderClaimDriver.d.ts.map +1 -0
  47. package/dist/GuestOrderClaimDriver.js +161 -0
  48. package/dist/GuestOrderClaimDriver.js.map +1 -0
  49. package/dist/ListEntitlementsOperation.d.ts +21 -0
  50. package/dist/ListEntitlementsOperation.d.ts.map +1 -0
  51. package/dist/ListEntitlementsOperation.js +39 -0
  52. package/dist/ListEntitlementsOperation.js.map +1 -0
  53. package/dist/OrderEntityServer.d.ts.map +1 -1
  54. package/dist/OrderEntityServer.js +2 -5
  55. package/dist/OrderEntityServer.js.map +1 -1
  56. package/dist/OrdersSettings.d.ts +5 -0
  57. package/dist/OrdersSettings.d.ts.map +1 -1
  58. package/dist/OrdersSettings.js +46 -5
  59. package/dist/OrdersSettings.js.map +1 -1
  60. package/dist/PaymentWebhookHandler.d.ts.map +1 -1
  61. package/dist/PaymentWebhookHandler.js +52 -3
  62. package/dist/PaymentWebhookHandler.js.map +1 -1
  63. package/dist/PreviewPriceOperation.d.ts +10 -17
  64. package/dist/PreviewPriceOperation.d.ts.map +1 -1
  65. package/dist/PreviewPriceOperation.js +40 -28
  66. package/dist/PreviewPriceOperation.js.map +1 -1
  67. package/dist/PriceOrderOperation.d.ts +5 -8
  68. package/dist/PriceOrderOperation.d.ts.map +1 -1
  69. package/dist/PriceOrderOperation.js +5 -8
  70. package/dist/PriceOrderOperation.js.map +1 -1
  71. package/dist/StripePaymentProvider.d.ts +13 -1
  72. package/dist/StripePaymentProvider.d.ts.map +1 -1
  73. package/dist/StripePaymentProvider.js +73 -15
  74. package/dist/StripePaymentProvider.js.map +1 -1
  75. package/dist/checkoutCaptureAlert.d.ts +16 -0
  76. package/dist/checkoutCaptureAlert.d.ts.map +1 -0
  77. package/dist/checkoutCaptureAlert.js +55 -0
  78. package/dist/checkoutCaptureAlert.js.map +1 -0
  79. package/dist/checkoutCaptureRetry.d.ts +27 -0
  80. package/dist/checkoutCaptureRetry.d.ts.map +1 -0
  81. package/dist/checkoutCaptureRetry.js +50 -0
  82. package/dist/checkoutCaptureRetry.js.map +1 -0
  83. package/dist/claimDriverHelpers.d.ts +15 -0
  84. package/dist/claimDriverHelpers.d.ts.map +1 -0
  85. package/dist/claimDriverHelpers.js +34 -0
  86. package/dist/claimDriverHelpers.js.map +1 -0
  87. package/dist/identityClaimContracts.d.ts +118 -0
  88. package/dist/identityClaimContracts.d.ts.map +1 -0
  89. package/dist/identityClaimContracts.js +58 -0
  90. package/dist/identityClaimContracts.js.map +1 -0
  91. package/dist/index.d.ts +17 -5
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +16 -3
  94. package/dist/index.js.map +1 -1
  95. package/dist/sql-guards.d.ts +16 -0
  96. package/dist/sql-guards.d.ts.map +1 -1
  97. package/dist/sql-guards.js +21 -0
  98. package/dist/sql-guards.js.map +1 -1
  99. package/package.json +17 -9
@@ -0,0 +1,1557 @@
1
+ /**
2
+ * @fileoverview CheckoutSessionService
3
+ *
4
+ * Provides generic server-side orchestration for anonymous checkout sessions, widget token
5
+ * initialization, draft order graph composition with polymorphic product extensions,
6
+ * and payment completion handling.
7
+ *
8
+ * @module @mj-biz-apps/orders-core-entities-server/CheckoutSessionService
9
+ */
10
+ import { LogError, Metadata, RunView } from '@memberjunction/core';
11
+ // Local fallback engine — see identityClaimContracts.ts. Restore this import to
12
+ // '@memberjunction/core-entities-server' once MJ publishes the engine.
13
+ import { IdentityClaimEngineServer } from './identityClaimContracts.js';
14
+ import { OrderPricingService } from '@mj-biz-apps/orders-entities';
15
+ import { EscapeText } from './sql-guards.js';
16
+ import { OpenPaymentIntent } from './PaymentIntentService.js';
17
+ import { ResolvePaymentProvider } from './PaymentProviderResolver.js';
18
+ import { CapturePaymentOperation } from './CapturePaymentOperation.js';
19
+ import { raiseCheckoutCaptureTerminalAlert } from './checkoutCaptureAlert.js';
20
+ import { isCaptureRefusalRetryable, isTerminalCapturePrecheck, } from './checkoutCaptureRetry.js';
21
+ const CHECKOUT_WIDGET_ENTITY = 'MJ_BizApps_Orders: Checkout Widgets';
22
+ const CHECKOUT_DISTRIBUTION_ENTITY = 'MJ_BizApps_Orders: Checkout Widget Distributions';
23
+ const CHECKOUT_SESSION_ENTITY = 'MJ_BizApps_Orders: Checkout Sessions';
24
+ const ORDER_HEADER_ENTITY = 'MJ_BizApps_Orders: Order Headers';
25
+ const PAYMENT_INTENT_ENTITY = 'MJ_BizApps_Orders: Payment Intents';
26
+ const PRODUCT_ENTITY = 'MJ_BizApps_Orders: Products';
27
+ const PRODUCT_TYPE_ENTITY = 'MJ_BizApps_Orders: Product Types';
28
+ const PERSON_ENTITY = 'MJ_BizApps_Common: People';
29
+ /**
30
+ * Server-side quantity ceiling applied when neither `Product.MaxQuantityPerLine` nor
31
+ * `ProductType.Configuration.maxQuantity` is configured. Without it, quantity — and the
32
+ * per-unit line expansion driven by attendee arrays — is unbounded from an anonymous caller.
33
+ */
34
+ const DEFAULT_MAX_QUANTITY_PER_LINE = 100;
35
+ /** Ceiling on the number of input lines a single checkout may carry. */
36
+ const MAX_LINES_PER_CHECKOUT = 50;
37
+ /** PaymentIntent statuses that satisfy the paid-order gate at completion. */
38
+ const SETTLED_INTENT_STATUSES = ['Succeeded'];
39
+ export class CheckoutSessionService {
40
+ /**
41
+ * Resolves a public distribution slug, validates widget status, and mints an active CheckoutSession.
42
+ */
43
+ static async InitializeSession(slug, clientSessionKey, contextUser) {
44
+ if (!slug) {
45
+ return { Success: false, ErrorMessage: 'Widget distribution slug is required' };
46
+ }
47
+ if (!clientSessionKey) {
48
+ return { Success: false, ErrorMessage: 'ClientSessionKey is required' };
49
+ }
50
+ const rv = new RunView();
51
+ const escapedSlug = EscapeText(slug.trim());
52
+ const distRes = await rv.RunView({
53
+ EntityName: CHECKOUT_DISTRIBUTION_ENTITY,
54
+ ExtraFilter: `Slug = '${escapedSlug}' AND Status = 'Active'`,
55
+ ResultType: 'entity_object'
56
+ }, contextUser);
57
+ if (!distRes.Success || !distRes.Results || distRes.Results.length === 0) {
58
+ return { Success: false, ErrorMessage: `Active checkout distribution not found for slug '${slug}'` };
59
+ }
60
+ const distribution = distRes.Results[0];
61
+ const md = new Metadata();
62
+ const widget = await md.GetEntityObject(CHECKOUT_WIDGET_ENTITY, contextUser);
63
+ const loadedWidget = await widget.Load(distribution.CheckoutWidgetID);
64
+ if (!loadedWidget || widget.Status !== 'Active') {
65
+ return { Success: false, ErrorMessage: 'The requested checkout widget is currently unavailable' };
66
+ }
67
+ // Check for existing unexpired session for this client. An ISO-8601 literal compares
68
+ // correctly on both SQL Server and PostgreSQL — GETUTCDATE() is T-SQL-only.
69
+ const escapedKey = EscapeText(clientSessionKey.trim());
70
+ const nowUtc = new Date().toISOString();
71
+ const sessRes = await rv.RunView({
72
+ EntityName: CHECKOUT_SESSION_ENTITY,
73
+ ExtraFilter: `CheckoutWidgetID = '${widget.ID}' AND ClientSessionKey = '${escapedKey}' AND Status = 'Open' AND ExpiresAt > '${nowUtc}'`,
74
+ ResultType: 'entity_object'
75
+ }, contextUser);
76
+ let session;
77
+ if (sessRes.Success && sessRes.Results && sessRes.Results.length > 0) {
78
+ session = sessRes.Results[0];
79
+ }
80
+ else {
81
+ session = await md.GetEntityObject(CHECKOUT_SESSION_ENTITY, contextUser);
82
+ session.NewRecord();
83
+ session.CheckoutWidgetID = widget.ID;
84
+ session.DistributionID = distribution.ID;
85
+ session.ClientSessionKey = clientSessionKey;
86
+ session.Status = 'Open';
87
+ const expires = new Date();
88
+ expires.setHours(expires.getHours() + 2); // 2 hour checkout session TTL
89
+ session.ExpiresAt = expires;
90
+ const saved = await session.Save();
91
+ if (!saved) {
92
+ return { Success: false, ErrorMessage: 'Failed to initialize checkout session record' };
93
+ }
94
+ }
95
+ let configObj = {};
96
+ if (widget.Configuration) {
97
+ try {
98
+ const parsed = JSON.parse(widget.Configuration);
99
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
100
+ throw new Error('Widget Configuration must be a JSON object');
101
+ }
102
+ configObj = parsed;
103
+ }
104
+ catch (err) {
105
+ return {
106
+ Success: false,
107
+ ErrorMessage: `Invalid widget configuration: ${err instanceof Error ? err.message : String(err)}`
108
+ };
109
+ }
110
+ }
111
+ // Ensure customUI section is populated, cascading from entity fields if needed
112
+ if (!configObj.customUI) {
113
+ configObj.customUI = {};
114
+ }
115
+ if (widget.CustomCSS && !configObj.customUI.css) {
116
+ configObj.customUI.css = widget.CustomCSS;
117
+ }
118
+ // Auto-discover extension fields from product type metadata if not explicitly provided
119
+ await this.discoverExtensionFields(configObj, md, contextUser);
120
+ return {
121
+ Success: true,
122
+ SessionID: session.ID,
123
+ ClientSessionKey: session.ClientSessionKey,
124
+ WidgetID: widget.ID,
125
+ WidgetName: widget.Name,
126
+ CompanyID: widget.CompanyID,
127
+ Configuration: this.sanitizeConfigurationForClient(configObj),
128
+ CustomCSS: configObj.customUI.css || widget.CustomCSS,
129
+ CustomJS: configObj.customUI.js || widget.CustomJS,
130
+ ExpiresAt: session.ExpiresAt.toISOString()
131
+ };
132
+ }
133
+ /**
134
+ * Strips obviously sensitive keys from a widget Configuration before it ships to an
135
+ * anonymous caller. `Configuration` is admin-authored JSON with an open index signature,
136
+ * so a future secret-shaped key (a webhook secret, an API credential, a server-side
137
+ * payment provider ref) must never ride along to the browser. `stripePublishableKey`
138
+ * is publishable by definition and survives.
139
+ */
140
+ /**
141
+ * Keys that may cross to an unauthenticated browser. Anything else in the
142
+ * admin-authored Configuration JSON stays on the server (allowlist, not denylist).
143
+ */
144
+ static { this.PUBLIC_CONFIGURATION_KEYS = new Set([
145
+ 'title',
146
+ 'description',
147
+ 'productId',
148
+ 'productSku',
149
+ 'productName',
150
+ 'unitPrice',
151
+ 'currency',
152
+ 'unitMode',
153
+ 'allowQuantity',
154
+ 'maxQuantity',
155
+ 'stripePublishableKey',
156
+ 'successMessage',
157
+ 'redirectUrl',
158
+ 'extensionEntityName',
159
+ 'extensionFields',
160
+ 'allowedOrigins',
161
+ 'requireTurnstile',
162
+ 'customUI',
163
+ 'isEvent',
164
+ 'theme',
165
+ 'allowCoupons',
166
+ ]); }
167
+ static sanitizeConfigurationForClient(configObj) {
168
+ const sanitized = {};
169
+ for (const [key, value] of Object.entries(configObj)) {
170
+ if (CheckoutSessionService.PUBLIC_CONFIGURATION_KEYS.has(key)) {
171
+ sanitized[key] = value;
172
+ }
173
+ }
174
+ return sanitized;
175
+ }
176
+ static idsEqual(a, b) {
177
+ if (!a || !b) {
178
+ return false;
179
+ }
180
+ return a.replace(/[{}]/g, '').toLowerCase() === b.replace(/[{}]/g, '').toLowerCase();
181
+ }
182
+ /**
183
+ * Catalog this anonymous session is allowed to sell. Explicit `productId` /
184
+ * `allowedProductIds` / SKU-resolved id. An empty result is a misconfigured
185
+ * anonymous widget unless `allowAnyProduct` is set (open catalog still
186
+ * requires a company match).
187
+ */
188
+ static async resolveAllowedProductIds(configObj, contextUser) {
189
+ const allowed = new Set();
190
+ if (typeof configObj.productId === 'string' && configObj.productId.trim()) {
191
+ allowed.add(configObj.productId.trim());
192
+ }
193
+ if (Array.isArray(configObj.allowedProductIds)) {
194
+ for (const id of configObj.allowedProductIds) {
195
+ if (typeof id === 'string' && id.trim()) {
196
+ allowed.add(id.trim());
197
+ }
198
+ }
199
+ }
200
+ if (allowed.size === 0 && typeof configObj.productSku === 'string' && configObj.productSku.trim()) {
201
+ const rv = new RunView();
202
+ const escapedSku = EscapeText(configObj.productSku.trim());
203
+ const prodRes = await rv.RunView({
204
+ EntityName: PRODUCT_ENTITY,
205
+ ExtraFilter: `SKU = '${escapedSku}'`,
206
+ ResultType: 'simple'
207
+ }, contextUser);
208
+ if (prodRes?.Success && prodRes.Results?.length) {
209
+ allowed.add(prodRes.Results[0].ID);
210
+ }
211
+ }
212
+ return allowed;
213
+ }
214
+ /** Marks expired-but-still-Open sessions Expired. Bounded; best-effort. */
215
+ static async ReapExpiredOpenSessions(contextUser, limit = 50) {
216
+ const rv = new RunView();
217
+ const nowUtc = new Date().toISOString();
218
+ const res = await rv.RunView({
219
+ EntityName: CHECKOUT_SESSION_ENTITY,
220
+ Fields: ['ID'],
221
+ ExtraFilter: `Status = 'Open' AND ExpiresAt <= '${nowUtc}'`,
222
+ MaxRows: limit,
223
+ ResultType: 'simple',
224
+ }, contextUser);
225
+ if (!res?.Success || !res.Results?.length) {
226
+ return 0;
227
+ }
228
+ const md = new Metadata();
229
+ let n = 0;
230
+ for (const row of res.Results.slice(0, limit)) {
231
+ try {
232
+ const session = await md.GetEntityObject(CHECKOUT_SESSION_ENTITY, contextUser);
233
+ if (!(await session.Load(row.ID))) {
234
+ continue;
235
+ }
236
+ if (session.Status !== 'Open') {
237
+ continue;
238
+ }
239
+ session.Status = 'Expired';
240
+ if (await session.Save()) {
241
+ n++;
242
+ }
243
+ }
244
+ catch (err) {
245
+ LogError(`[CheckoutSessionService] ReapExpiredOpenSessions failed for ${row.ID}: ${err instanceof Error ? err.message : String(err)}`);
246
+ }
247
+ }
248
+ return n;
249
+ }
250
+ /**
251
+ * Verifies a caller-presented client session key against the session row's stored key.
252
+ * The session GUID alone must never be sufficient to drive a session — both values are
253
+ * required on every mutating call. Constant-time comparison over char codes (this package
254
+ * deliberately avoids Node globals, so no crypto.timingSafeEqual); a length mismatch is a
255
+ * plain refusal — it leaks only the length, which the caller already knows from their own key.
256
+ */
257
+ static verifyClientSessionKey(session, clientSessionKey) {
258
+ const stored = session.ClientSessionKey ?? '';
259
+ const presented = (clientSessionKey ?? '').trim();
260
+ if (!stored || !presented || stored.length !== presented.length) {
261
+ return false;
262
+ }
263
+ let diff = 0;
264
+ for (let i = 0; i < stored.length; i++) {
265
+ diff |= stored.charCodeAt(i) ^ presented.charCodeAt(i);
266
+ }
267
+ return diff === 0;
268
+ }
269
+ /**
270
+ * Enforces the session TTL on mutating calls (it was previously only enforced at
271
+ * session reuse). An expired-but-Open session is transitioned to 'Expired' (best-effort)
272
+ * and refused.
273
+ */
274
+ static async enforceSessionExpiry(session) {
275
+ if (session.ExpiresAt && new Date(session.ExpiresAt).getTime() <= Date.now()) {
276
+ if (session.Status === 'Open') {
277
+ session.Status = 'Expired';
278
+ const saved = await session.Save();
279
+ if (!saved) {
280
+ LogError(`[CheckoutSessionService] Failed to mark session ${session.ID} Expired: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`);
281
+ }
282
+ }
283
+ return false;
284
+ }
285
+ return true;
286
+ }
287
+ /**
288
+ * Discovers extension field definitions from metadata for the target product's extension entity.
289
+ */
290
+ /**
291
+ * ProductType.Configuration is the reusable presentation layer (customUI CSS/JS/theme,
292
+ * unitMode, quantity). Widget Configuration overlays it; widget wins on conflict.
293
+ */
294
+ static mergeProductTypePresentation(configObj, productType) {
295
+ if (!productType.Configuration) {
296
+ return;
297
+ }
298
+ let pTypeConfig;
299
+ try {
300
+ const parsed = JSON.parse(productType.Configuration);
301
+ if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
302
+ return;
303
+ }
304
+ pTypeConfig = parsed;
305
+ }
306
+ catch {
307
+ return;
308
+ }
309
+ if (!configObj.unitMode && pTypeConfig.unitMode) {
310
+ configObj.unitMode = pTypeConfig.unitMode;
311
+ }
312
+ if (configObj.allowQuantity === undefined && pTypeConfig.allowQuantity !== undefined) {
313
+ configObj.allowQuantity = pTypeConfig.allowQuantity;
314
+ }
315
+ if (!configObj.maxQuantity && pTypeConfig.maxQuantity) {
316
+ configObj.maxQuantity = pTypeConfig.maxQuantity;
317
+ }
318
+ const typeUI = pTypeConfig.customUI;
319
+ if (!typeUI) {
320
+ return;
321
+ }
322
+ const widgetUI = configObj.customUI ?? {};
323
+ configObj.customUI = {
324
+ ...typeUI,
325
+ ...widgetUI,
326
+ theme: { ...(typeUI.theme ?? {}), ...(widgetUI.theme ?? {}) },
327
+ css: widgetUI.css || typeUI.css,
328
+ js: widgetUI.js || typeUI.js,
329
+ };
330
+ }
331
+ static async discoverExtensionFields(configObj, md, contextUser) {
332
+ let productId = typeof configObj.productId === 'string' ? configObj.productId.trim() || undefined : undefined;
333
+ const productSku = typeof configObj.productSku === 'string' ? configObj.productSku : undefined;
334
+ if (!productId && productSku) {
335
+ const rv = new RunView();
336
+ const escapedSku = EscapeText(productSku.trim());
337
+ const prodRes = await rv.RunView({
338
+ EntityName: PRODUCT_ENTITY,
339
+ ExtraFilter: `SKU = '${escapedSku}'`,
340
+ ResultType: 'simple'
341
+ }, contextUser);
342
+ if (prodRes?.Success && prodRes.Results && prodRes.Results.length > 0) {
343
+ productId = prodRes.Results[0].ID;
344
+ }
345
+ }
346
+ // Always write the resolved id back so a SKU-only widget still gives the
347
+ // public host page a ProductID it can draft a line with.
348
+ if (productId && !String(configObj.productId ?? '').trim()) {
349
+ configObj.productId = productId;
350
+ }
351
+ if (!productId)
352
+ return;
353
+ const product = await md.GetEntityObject(PRODUCT_ENTITY, contextUser);
354
+ const prodLoaded = await product.Load(productId);
355
+ if (!prodLoaded)
356
+ return;
357
+ if (!configObj.productName && product.Name) {
358
+ configObj.productName = product.Name;
359
+ }
360
+ if (!product.ProductTypeID)
361
+ return;
362
+ const productType = await md.GetEntityObject(PRODUCT_TYPE_ENTITY, contextUser);
363
+ const typeLoaded = await productType.Load(product.ProductTypeID);
364
+ if (!typeLoaded)
365
+ return;
366
+ this.mergeProductTypePresentation(configObj, productType);
367
+ if (Array.isArray(configObj.extensionFields) && configObj.extensionFields.length > 0) {
368
+ return;
369
+ }
370
+ if (!productType.OrderLineExtensionEntity)
371
+ return;
372
+ const extEntityName = productType.OrderLineExtensionEntity;
373
+ configObj.extensionEntityName = extEntityName;
374
+ const extEntityInfo = md.Entities.find(e => e.Name === extEntityName);
375
+ if (!extEntityInfo)
376
+ return;
377
+ const discoveredFields = [];
378
+ // Check if extension links to a Person
379
+ const hasPerson = extEntityInfo.Fields.some(f => f.Name.toLowerCase() === 'personid');
380
+ if (hasPerson) {
381
+ discoveredFields.push({ name: 'firstName', label: 'First Name', type: 'text', required: true, placeholder: 'Jane' }, { name: 'lastName', label: 'Last Name', type: 'text', required: true, placeholder: 'Doe' }, { name: 'email', label: 'Email Address', type: 'text', required: true, placeholder: 'jane.doe@example.com' }, { name: 'company', label: 'Company / Organization', type: 'text', required: false, placeholder: 'Acme Corp' }, { name: 'title', label: 'Job Title', type: 'text', required: false, placeholder: 'Director' });
382
+ }
383
+ const excluded = new Set(['id', 'personid', 'checkinat']);
384
+ const parentFields = new Set();
385
+ if (extEntityInfo.ParentEntityFieldNames) {
386
+ for (const n of extEntityInfo.ParentEntityFieldNames) {
387
+ parentFields.add(n.toLowerCase());
388
+ }
389
+ }
390
+ for (const f of extEntityInfo.Fields) {
391
+ const lower = f.Name.toLowerCase();
392
+ if (excluded.has(lower) || parentFields.has(lower) || lower.startsWith('__mj_') || f.IsPrimaryKey || f.IsVirtual || !f.AllowUpdateAPI) {
393
+ continue;
394
+ }
395
+ let fieldType = 'text';
396
+ const sqlType = (f.Type || '').toLowerCase();
397
+ const valList = (f.ValueListType || '').toLowerCase();
398
+ if (valList === 'list' || valList === 'listoruserentry') {
399
+ fieldType = 'select';
400
+ }
401
+ else if (sqlType === 'bit') {
402
+ fieldType = 'boolean';
403
+ }
404
+ else if (['date', 'datetime', 'datetime2', 'datetimeoffset', 'smalldatetime'].includes(sqlType)) {
405
+ fieldType = 'date';
406
+ }
407
+ else if (['int', 'bigint', 'smallint', 'tinyint', 'decimal', 'numeric', 'float', 'real', 'money', 'smallmoney'].includes(sqlType)) {
408
+ fieldType = 'number';
409
+ }
410
+ else if (['ntext', 'text', 'nvarchar(max)', 'varchar(max)'].includes(sqlType) || (f.Length && f.Length > 255)) {
411
+ fieldType = 'textarea';
412
+ }
413
+ const camelName = f.Name.charAt(0).toLowerCase() + f.Name.slice(1);
414
+ const label = f.DisplayName?.trim() || f.Name.replace(/([a-z])([A-Z])/g, '$1 $2');
415
+ discoveredFields.push({
416
+ name: camelName,
417
+ label,
418
+ type: fieldType,
419
+ required: !f.AllowsNull,
420
+ placeholder: f.Description || label
421
+ });
422
+ }
423
+ configObj.extensionFields = discoveredFields;
424
+ }
425
+ /**
426
+ * Resolves — and optionally creates — a Person entity record based on provided fields.
427
+ * Lookup is by normalized email. Creation only happens when `createIfMissing` is true:
428
+ * draft updates resolve-only (so abandoned checkouts never mint Person rows), while
429
+ * completion creates the payer/attendee records it genuinely needs.
430
+ */
431
+ static async resolveOrEnsurePerson(fields, contextUser, createIfMissing = true) {
432
+ const email = (fields['Email'] || fields['email'] || fields['AttendeeEmail'] || fields['attendeeEmail']);
433
+ if (!email || !email.trim()) {
434
+ return null;
435
+ }
436
+ const normalized = email.trim().toLowerCase();
437
+ const rv = new RunView();
438
+ const escaped = EscapeText(normalized);
439
+ try {
440
+ const personRes = await rv.RunView({
441
+ EntityName: PERSON_ENTITY,
442
+ ExtraFilter: `Email = '${escaped}'`,
443
+ ResultType: 'simple'
444
+ }, contextUser);
445
+ if (personRes?.Success && personRes.Results && personRes.Results.length > 0) {
446
+ return personRes.Results[0].ID;
447
+ }
448
+ }
449
+ catch (err) {
450
+ console.warn('[CheckoutSessionService] RunView Person lookup error:', err);
451
+ }
452
+ if (!createIfMissing) {
453
+ return null;
454
+ }
455
+ let firstName = (fields['FirstName'] || fields['firstName'] || '');
456
+ let lastName = (fields['LastName'] || fields['lastName'] || '');
457
+ if (!firstName && !lastName && (fields['AttendeeName'] || fields['Name'] || fields['name'])) {
458
+ const fullName = String(fields['AttendeeName'] || fields['Name'] || fields['name']).trim();
459
+ const parts = fullName.split(/\s+/);
460
+ firstName = parts[0] || '';
461
+ lastName = parts.slice(1).join(' ') || '';
462
+ }
463
+ if (firstName || lastName) {
464
+ try {
465
+ const md = new Metadata();
466
+ const person = await md.GetEntityObject(PERSON_ENTITY, contextUser);
467
+ if (person) {
468
+ person.NewRecord();
469
+ person.Set('FirstName', firstName);
470
+ person.Set('LastName', lastName || firstName);
471
+ person.Set('Email', normalized);
472
+ if (fields['Title'] || fields['title']) {
473
+ person.Set('Title', String(fields['Title'] || fields['title']));
474
+ }
475
+ if (fields['Phone'] || fields['phone']) {
476
+ person.Set('Phone', String(fields['Phone'] || fields['phone']));
477
+ }
478
+ const saved = await person.Save();
479
+ if (saved) {
480
+ return person.Get('ID');
481
+ }
482
+ else {
483
+ console.error('[CheckoutSessionService] Person save error:', person.LatestResult?.CompleteMessage);
484
+ }
485
+ }
486
+ }
487
+ catch (err) {
488
+ console.error('[CheckoutSessionService] Person creation exception:', err);
489
+ }
490
+ }
491
+ return null;
492
+ }
493
+ /**
494
+ * Creates an order line entity instance attached to the order's Lines collection.
495
+ */
496
+ static async createOrderLine(order, targetExtensionEntity, _md, _contextUser) {
497
+ const line = (await order.Lines.Create());
498
+ if (targetExtensionEntity) {
499
+ await line.Extension.EnsureEntity(targetExtensionEntity);
500
+ }
501
+ return line;
502
+ }
503
+ /**
504
+ * Dynamically populates and coerces extension companion properties from a field map.
505
+ */
506
+ static async hydrateLineExtension(line, extensionEntityName, fieldValues, contextUser, createPersons = true) {
507
+ if (!fieldValues || Object.keys(fieldValues).length === 0) {
508
+ return;
509
+ }
510
+ // Build descriptive summary for the line card if names/email are present
511
+ const firstName = fieldValues['FirstName'] || fieldValues['firstName'];
512
+ const lastName = fieldValues['LastName'] || fieldValues['lastName'];
513
+ const email = fieldValues['Email'] || fieldValues['email'];
514
+ const nameOrEmail = [
515
+ firstName,
516
+ lastName,
517
+ email ? `(${email})` : ''
518
+ ].filter(Boolean).join(' ');
519
+ if (nameOrEmail) {
520
+ line.Description = String(nameOrEmail);
521
+ }
522
+ // Auto-resolve PersonID if person fields exist on the entity or extension. Person
523
+ // creation only happens on the completion path — draft pricing passes createPersons=false
524
+ // so abandoned checkouts never mint Person rows.
525
+ const resolvedPersonID = await this.resolveOrEnsurePerson(fieldValues, contextUser, createPersons);
526
+ const excludedClientFields = new Set([
527
+ 'id',
528
+ 'orderlineid',
529
+ 'personid',
530
+ 'attendeepersonid',
531
+ 'checkinat',
532
+ 'checkedin',
533
+ '__mj_createdat',
534
+ '__mj_updatedat'
535
+ ]);
536
+ if (extensionEntityName && line.Extension) {
537
+ const ext = await line.Extension.EnsureEntity(extensionEntityName);
538
+ if (ext) {
539
+ const extFieldsByName = new Map((ext.EntityInfo?.Fields ?? []).map((f) => [f.Name.toLowerCase(), f]));
540
+ if (resolvedPersonID) {
541
+ const extPField = extFieldsByName.get('personid') || extFieldsByName.get('attendeepersonid');
542
+ if (extPField && !extPField.IsPrimaryKey && !extPField.IsVirtual && extPField.AllowUpdateAPI) {
543
+ ext.Set(extPField.Name, resolvedPersonID);
544
+ }
545
+ }
546
+ for (const [key, rawValue] of Object.entries(fieldValues)) {
547
+ const lowerKey = key.toLowerCase();
548
+ if (excludedClientFields.has(lowerKey) || lowerKey.startsWith('__mj_')) {
549
+ continue;
550
+ }
551
+ const field = extFieldsByName.get(lowerKey);
552
+ if (field && !field.IsPrimaryKey && !field.IsVirtual && field.AllowUpdateAPI) {
553
+ let coerced = rawValue;
554
+ if (field.Type.toLowerCase().includes('int') && typeof rawValue === 'string') {
555
+ coerced = parseInt(rawValue, 10);
556
+ }
557
+ else if ((field.Type.toLowerCase().includes('decimal') || field.Type.toLowerCase().includes('money')) && typeof rawValue === 'string') {
558
+ coerced = parseFloat(rawValue);
559
+ }
560
+ else if (field.Type.toLowerCase() === 'bit' && typeof rawValue === 'string') {
561
+ coerced = rawValue === 'true' || rawValue === '1';
562
+ }
563
+ ext.Set(field.Name, coerced);
564
+ }
565
+ }
566
+ }
567
+ }
568
+ }
569
+ /**
570
+ * Extracts unit field maps from an input line across all polymorphic formats.
571
+ */
572
+ static extractUnitPayloads(inputLine) {
573
+ if (inputLine.ExtensionData?.Units && inputLine.ExtensionData.Units.length > 0) {
574
+ return inputLine.ExtensionData.Units;
575
+ }
576
+ if (inputLine.Units && inputLine.Units.length > 0) {
577
+ return inputLine.Units;
578
+ }
579
+ if (inputLine.Attendees && inputLine.Attendees.length > 0) {
580
+ return inputLine.Attendees.map(a => ({
581
+ FirstName: a.FirstName,
582
+ LastName: a.LastName,
583
+ Email: a.Email,
584
+ Company: a.Company,
585
+ PersonID: a.PersonID,
586
+ DietaryPreferences: a.DietaryPreferences,
587
+ Comments: a.Comments
588
+ }));
589
+ }
590
+ if (inputLine.ExtensionData?.Fields && Object.keys(inputLine.ExtensionData.Fields).length > 0) {
591
+ return [inputLine.ExtensionData.Fields];
592
+ }
593
+ if (inputLine.ExtensionFields && Object.keys(inputLine.ExtensionFields).length > 0) {
594
+ return [inputLine.ExtensionFields];
595
+ }
596
+ return [];
597
+ }
598
+ /**
599
+ * Builds and prices draft order lines in memory, persisting the checkout state to the
600
+ * CheckoutSession without creating premature draft rows in the OrderHeader database table.
601
+ */
602
+ static async UpdateDraft(sessionID, clientSessionKey, email, lines, contextUser) {
603
+ const failed = (message) => ({
604
+ Success: false,
605
+ ErrorMessage: message,
606
+ SessionID: sessionID ?? '',
607
+ Subtotal: 0,
608
+ Tax: 0,
609
+ Adjustments: 0,
610
+ TotalGross: 0,
611
+ RequiresPayment: false,
612
+ Lines: []
613
+ });
614
+ if (!sessionID) {
615
+ return failed('Session ID is required.');
616
+ }
617
+ if (!Array.isArray(lines) || lines.length > MAX_LINES_PER_CHECKOUT) {
618
+ return failed(`A checkout may carry at most ${MAX_LINES_PER_CHECKOUT} lines.`);
619
+ }
620
+ const md = new Metadata();
621
+ const session = await md.GetEntityObject(CHECKOUT_SESSION_ENTITY, contextUser);
622
+ const sessionLoaded = await session.Load(sessionID);
623
+ if (!sessionLoaded || session.Status !== 'Open') {
624
+ return failed('Checkout session is not valid or has expired.');
625
+ }
626
+ if (!this.verifyClientSessionKey(session, clientSessionKey)) {
627
+ return failed('Checkout session key does not match.');
628
+ }
629
+ if (!(await this.enforceSessionExpiry(session))) {
630
+ return failed('Checkout session has expired.');
631
+ }
632
+ const widget = await md.GetEntityObject(CHECKOUT_WIDGET_ENTITY, contextUser);
633
+ await widget.Load(session.CheckoutWidgetID);
634
+ let widgetConfig = {};
635
+ if (widget.Configuration) {
636
+ try {
637
+ const parsed = JSON.parse(widget.Configuration);
638
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
639
+ widgetConfig = parsed;
640
+ }
641
+ }
642
+ catch {
643
+ return failed('Invalid widget configuration');
644
+ }
645
+ }
646
+ const allowedProductIds = await this.resolveAllowedProductIds(widgetConfig, contextUser);
647
+ const allowAnyProduct = widgetConfig.allowAnyProduct === true;
648
+ if (!allowAnyProduct && allowedProductIds.size === 0) {
649
+ return failed('This checkout is not configured with a product');
650
+ }
651
+ // Open catalog is company-scoped. A widget with no CompanyID must not accept an
652
+ // arbitrary ProductID from an anonymous caller (the remaining company check used
653
+ // to skip when CompanyID was unset).
654
+ if (allowAnyProduct && !widget.CompanyID) {
655
+ return failed('This checkout is not configured for open-catalog sale');
656
+ }
657
+ // Build in-memory Order graph for accurate pricing calculation without DB pollution
658
+ const order = await md.GetEntityObject(ORDER_HEADER_ENTITY, contextUser);
659
+ order.NewRecord();
660
+ order.CompanyID = widget.CompanyID;
661
+ order.Status = 'Draft';
662
+ order.Origin = 'Widget';
663
+ order.SourceCheckoutWidgetID = widget.ID;
664
+ order.OrderDate = new Date();
665
+ const normalizedEmail = (email || '').trim().toLowerCase();
666
+ session.Email = normalizedEmail;
667
+ // Resolve (never create) the payer Person by email so person-specific pricing applies
668
+ // to the draft. Creation is deferred to completion — abandoned drafts must not mint
669
+ // Person rows.
670
+ if (!session.PersonID && normalizedEmail) {
671
+ const resolvedPersonID = await this.resolveOrEnsurePerson({ Email: normalizedEmail }, contextUser, false);
672
+ if (resolvedPersonID) {
673
+ session.PersonID = resolvedPersonID;
674
+ }
675
+ }
676
+ if (session.PersonID) {
677
+ order.BillToPersonID = session.PersonID;
678
+ order.ShipToPersonID = session.PersonID;
679
+ }
680
+ let sequence = 1;
681
+ for (const inputLine of lines) {
682
+ // Validate quantity: must be positive integer >= 1
683
+ if (!Number.isInteger(inputLine.Quantity) || inputLine.Quantity < 1) {
684
+ return failed(`Invalid quantity: quantity must be a positive integer (received ${inputLine.Quantity})`);
685
+ }
686
+ let targetExtensionEntity = null;
687
+ let maxQuantityPerLine = null;
688
+ let unitMode = 'perLine';
689
+ if (!inputLine.ProductID) {
690
+ return failed('ProductID is required on every checkout line');
691
+ }
692
+ if (!allowAnyProduct && ![...allowedProductIds].some((id) => this.idsEqual(id, inputLine.ProductID))) {
693
+ return failed('This checkout does not sell that product');
694
+ }
695
+ try {
696
+ const product = await md.GetEntityObject(PRODUCT_ENTITY, contextUser);
697
+ const prodLoaded = await product.Load(inputLine.ProductID);
698
+ if (!prodLoaded) {
699
+ return failed('The requested product is not available');
700
+ }
701
+ if (!this.idsEqual(widget.CompanyID, String(product.CompanyID ?? ''))) {
702
+ return failed('This checkout does not sell that product');
703
+ }
704
+ maxQuantityPerLine = product.MaxQuantityPerLine ?? null;
705
+ if (product.ProductTypeID) {
706
+ const pType = await md.GetEntityObject(PRODUCT_TYPE_ENTITY, contextUser);
707
+ const pTypeLoaded = await pType.Load(product.ProductTypeID);
708
+ if (pTypeLoaded) {
709
+ targetExtensionEntity = pType.OrderLineExtensionEntity ?? null;
710
+ if (pType.Configuration) {
711
+ try {
712
+ const pTypeConfig = JSON.parse(pType.Configuration);
713
+ if (pTypeConfig.unitMode) {
714
+ unitMode = pTypeConfig.unitMode;
715
+ }
716
+ if (pTypeConfig.maxQuantity && !maxQuantityPerLine) {
717
+ maxQuantityPerLine = pTypeConfig.maxQuantity;
718
+ }
719
+ }
720
+ catch {
721
+ // Ignore malformed JSON in Configuration
722
+ }
723
+ }
724
+ }
725
+ }
726
+ }
727
+ catch (err) {
728
+ LogError(`[CheckoutSessionService] Product load failed for ${inputLine.ProductID}: ${err instanceof Error ? err.message : String(err)}`);
729
+ return failed('The requested product is not available');
730
+ }
731
+ const effectiveMax = maxQuantityPerLine ?? DEFAULT_MAX_QUANTITY_PER_LINE;
732
+ if (inputLine.Quantity > effectiveMax) {
733
+ return failed(`Quantity ${inputLine.Quantity} exceeds maximum allowed quantity of ${effectiveMax} for this item`);
734
+ }
735
+ const unitPayloads = this.extractUnitPayloads(inputLine);
736
+ if (unitPayloads.length > effectiveMax) {
737
+ return failed(`Unit payload count ${unitPayloads.length} exceeds maximum allowed quantity of ${effectiveMax} for this item`);
738
+ }
739
+ if ((unitMode === 'perUnit' || unitPayloads.length > 1) && unitPayloads.length > 0) {
740
+ for (const unitData of unitPayloads) {
741
+ const line = await this.createOrderLine(order, targetExtensionEntity, md, contextUser);
742
+ line.ProductID = inputLine.ProductID;
743
+ line.Quantity = 1;
744
+ line.LineNumber = sequence++;
745
+ await this.hydrateLineExtension(line, targetExtensionEntity, unitData, contextUser, false);
746
+ }
747
+ }
748
+ else {
749
+ const line = await this.createOrderLine(order, targetExtensionEntity, md, contextUser);
750
+ line.ProductID = inputLine.ProductID;
751
+ line.Quantity = inputLine.Quantity;
752
+ line.LineNumber = sequence++;
753
+ if (unitPayloads.length > 0) {
754
+ await this.hydrateLineExtension(line, targetExtensionEntity, unitPayloads[0], contextUser, false);
755
+ }
756
+ }
757
+ }
758
+ // Price the draft order in memory
759
+ try {
760
+ const pricingService = new OrderPricingService({
761
+ Provider: (order.ProviderToUse ?? md),
762
+ User: contextUser ?? order.ContextCurrentUser,
763
+ });
764
+ await pricingService.Price({
765
+ OrderHeaderID: order.ID || null,
766
+ CompanyID: widget.CompanyID,
767
+ BillToPersonID: order.BillToPersonID ?? null,
768
+ BillToOrganizationID: order.BillToOrganizationID ?? null,
769
+ OrderDate: order.OrderDate ?? new Date(),
770
+ ShipToAddressID: order.ShipToAddressID ?? null,
771
+ Lines: [...order.Lines.Items],
772
+ PromotionCodes: [],
773
+ ManualDiscounts: [],
774
+ Charges: [],
775
+ });
776
+ let sumGross = 0;
777
+ for (const line of order.Lines.Items) {
778
+ const extPrice = line.LineTotalGross ?? ((line.UnitPrice ?? 0) * (line.Quantity ?? 1));
779
+ sumGross += extPrice;
780
+ }
781
+ order.TotalGross = Math.round(sumGross * 100) / 100;
782
+ }
783
+ catch (pricingErr) {
784
+ console.warn('[CheckoutSessionService] Pricing walk error on draft:', pricingErr);
785
+ }
786
+ // Build line summaries from the in-memory priced order graph
787
+ const lineSummaries = order.Lines.Items.map(l => ({
788
+ ID: l.ID || `line-${l.LineNumber}`,
789
+ ProductID: l.ProductID,
790
+ Quantity: l.Quantity,
791
+ UnitPrice: l.UnitPrice ?? 0,
792
+ ExtendedPrice: l.LineTotalGross ?? ((l.UnitPrice ?? 0) * (l.Quantity ?? 1)),
793
+ Description: l.Description ?? undefined
794
+ }));
795
+ // A previously opened payment intent was priced against the OLD snapshot. If the total
796
+ // changed, detach it so the completion gate can't accept a stale (higher OR lower)
797
+ // authorized amount — the caller must reopen the intent against the new total.
798
+ let previousTotal;
799
+ if (session.MetadataJSON) {
800
+ try {
801
+ previousTotal = JSON.parse(session.MetadataJSON).TotalGross;
802
+ }
803
+ catch {
804
+ previousTotal = undefined;
805
+ }
806
+ }
807
+ if (session.PaymentIntentID && previousTotal !== order.TotalGross) {
808
+ session.PaymentIntentID = null;
809
+ }
810
+ // Store checkout state in session metadata JSON — no orphan OrderHeader rows
811
+ session.MetadataJSON = JSON.stringify({
812
+ Lines: lines,
813
+ PricedLines: lineSummaries,
814
+ TotalGross: order.TotalGross,
815
+ UpdatedAt: new Date().toISOString()
816
+ });
817
+ const sessionSaved = await session.Save();
818
+ if (!sessionSaved) {
819
+ return failed(`Failed to persist checkout state: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`);
820
+ }
821
+ return {
822
+ Success: true,
823
+ SessionID: sessionID,
824
+ OrderID: session.DraftOrderID || '',
825
+ OrderNumber: '',
826
+ Subtotal: order.TotalGross ?? 0,
827
+ Tax: 0,
828
+ Adjustments: 0,
829
+ TotalGross: order.TotalGross ?? 0,
830
+ RequiresPayment: (order.TotalGross ?? 0) > 0,
831
+ Lines: lineSummaries
832
+ };
833
+ }
834
+ /**
835
+ * Opens (or idempotently re-opens) a payment intent for a session's CURRENT server-priced
836
+ * total. This is the only way a checkout session acquires a `PaymentIntentID`.
837
+ *
838
+ * Everything money-shaped is resolved server-side, per the checkout pricing-input rule:
839
+ * the amount comes from the session's own priced snapshot (never the client), and the
840
+ * payment provider comes from the widget's admin-authored Configuration
841
+ * (`paymentProviderId`) — the client supplies only its session id + key. The gateway
842
+ * client secret is returned for Stripe.js confirmation and never persisted.
843
+ */
844
+ static async OpenPaymentIntentForSession(sessionID, clientSessionKey, contextUser) {
845
+ const failed = (message) => ({
846
+ Success: false,
847
+ ErrorMessage: message,
848
+ SessionID: sessionID ?? ''
849
+ });
850
+ if (!sessionID) {
851
+ return failed('Session ID is required.');
852
+ }
853
+ if (!contextUser) {
854
+ return failed('A context user is required to open a payment intent.');
855
+ }
856
+ const md = new Metadata();
857
+ const session = await md.GetEntityObject(CHECKOUT_SESSION_ENTITY, contextUser);
858
+ const loaded = await session.Load(sessionID);
859
+ if (!loaded || session.Status !== 'Open') {
860
+ return failed('Checkout session is not valid or has expired.');
861
+ }
862
+ if (!this.verifyClientSessionKey(session, clientSessionKey)) {
863
+ return failed('Checkout session key does not match.');
864
+ }
865
+ if (!(await this.enforceSessionExpiry(session))) {
866
+ return failed('Checkout session has expired.');
867
+ }
868
+ // The amount is the session's own server-priced snapshot — never a caller input.
869
+ let snapshotTotal = 0;
870
+ if (session.MetadataJSON) {
871
+ try {
872
+ const parsed = JSON.parse(session.MetadataJSON);
873
+ snapshotTotal = typeof parsed.TotalGross === 'number' ? parsed.TotalGross : 0;
874
+ }
875
+ catch {
876
+ snapshotTotal = 0;
877
+ }
878
+ }
879
+ if (!(snapshotTotal > 0)) {
880
+ return failed('This checkout has no balance due — complete it directly without a payment intent.');
881
+ }
882
+ const widget = await md.GetEntityObject(CHECKOUT_WIDGET_ENTITY, contextUser);
883
+ const widgetLoaded = await widget.Load(session.CheckoutWidgetID);
884
+ if (!widgetLoaded) {
885
+ return failed('The checkout widget for this session could not be loaded.');
886
+ }
887
+ let paymentProviderId;
888
+ let currencyCode;
889
+ if (widget.Configuration) {
890
+ try {
891
+ const configObj = JSON.parse(widget.Configuration);
892
+ paymentProviderId = typeof configObj.paymentProviderId === 'string' ? configObj.paymentProviderId : undefined;
893
+ currencyCode = typeof configObj.currency === 'string' ? configObj.currency : undefined;
894
+ }
895
+ catch {
896
+ // Malformed configuration already fails InitializeSession; treat as unset here.
897
+ }
898
+ }
899
+ if (!paymentProviderId) {
900
+ return failed(`Checkout widget '${widget.Name}' has no paymentProviderId configured — a paid checkout requires one.`);
901
+ }
902
+ const mdProvider = Metadata.Provider;
903
+ if (!mdProvider) {
904
+ return failed('No metadata provider is available to open a payment intent.');
905
+ }
906
+ const openResult = await OpenPaymentIntent({
907
+ PaymentProviderID: paymentProviderId,
908
+ Amount: snapshotTotal,
909
+ CurrencyCode: currencyCode,
910
+ BillToPersonID: session.PersonID ?? undefined,
911
+ // Stable per-session idempotency key: reopening for the same session+amount
912
+ // returns the SAME gateway intent instead of minting a fresh one per retry.
913
+ IdempotencyKey: `checkout-${sessionID}-${Math.round(snapshotTotal * 100)}`,
914
+ Metadata: { CheckoutSessionID: sessionID }
915
+ }, mdProvider, contextUser);
916
+ if (!openResult.Success || !openResult.PaymentIntentID) {
917
+ return failed(openResult.Reason ?? 'The payment provider refused to open an intent.');
918
+ }
919
+ session.PaymentIntentID = openResult.PaymentIntentID;
920
+ const saved = await session.Save();
921
+ if (!saved) {
922
+ return failed(`Failed to attach the payment intent to the session: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`);
923
+ }
924
+ return {
925
+ Success: true,
926
+ SessionID: sessionID,
927
+ PaymentIntentID: openResult.PaymentIntentID,
928
+ ClientSecret: openResult.ClientSecret,
929
+ Status: openResult.Status,
930
+ Amount: snapshotTotal
931
+ };
932
+ }
933
+ /**
934
+ * Completes an existing CheckoutSession, constructing the final Order,
935
+ * executing lifecycle confirmation (with accounting GL bookings and deferred revenue),
936
+ * and generating a claim token if unauthenticated.
937
+ */
938
+ static async CompleteCheckout(sessionID, clientSessionKey, contextUser) {
939
+ const md = new Metadata();
940
+ const session = await md.GetEntityObject(CHECKOUT_SESSION_ENTITY, contextUser);
941
+ const loaded = await session.Load(sessionID);
942
+ if (!loaded) {
943
+ return {
944
+ Success: false,
945
+ ErrorMessage: 'Checkout session not found',
946
+ SessionID: sessionID,
947
+ Status: 'Unknown'
948
+ };
949
+ }
950
+ if (!this.verifyClientSessionKey(session, clientSessionKey)) {
951
+ return {
952
+ Success: false,
953
+ ErrorMessage: 'Checkout session key does not match',
954
+ SessionID: sessionID,
955
+ Status: session.Status
956
+ };
957
+ }
958
+ // Replay safety: a session that already completed returns its existing order rather
959
+ // than an error — a network retry after a successful booking must never double-book
960
+ // and must never strand the buyer without their order id.
961
+ if (session.Status === 'Confirmed' && session.DraftOrderID) {
962
+ const existingOrder = await md.GetEntityObject(ORDER_HEADER_ENTITY, contextUser);
963
+ const orderLoaded = await existingOrder.Load(session.DraftOrderID);
964
+ // A first complete can confirm the order and then fail to book the payment.
965
+ // Replay is one retry: CapturePayment is idempotent on `checkout-complete:${session.ID}`.
966
+ // `payment_intent.succeeded` is the other — BookSettledCheckoutPaymentIfNeeded, including
967
+ // on AlreadyApplied webhook deliveries so a 500 after applyEvent still converges.
968
+ if (orderLoaded) {
969
+ await this.applySettledPaymentToOrder(session, existingOrder, contextUser);
970
+ }
971
+ return {
972
+ Success: true,
973
+ SessionID: sessionID,
974
+ Status: 'Confirmed',
975
+ OrderID: session.DraftOrderID,
976
+ OrderNumber: orderLoaded ? (existingOrder.OrderNumber || session.DraftOrderID) : session.DraftOrderID,
977
+ TotalGross: orderLoaded ? (existingOrder.TotalGross ?? undefined) : undefined
978
+ };
979
+ }
980
+ if (session.Status !== 'Open') {
981
+ return {
982
+ Success: false,
983
+ ErrorMessage: 'Session is not in an Open status',
984
+ SessionID: sessionID,
985
+ Status: session.Status
986
+ };
987
+ }
988
+ if (!(await this.enforceSessionExpiry(session))) {
989
+ return {
990
+ Success: false,
991
+ ErrorMessage: 'Checkout session has expired',
992
+ SessionID: sessionID,
993
+ Status: 'Expired'
994
+ };
995
+ }
996
+ // Atomically latch status to Processing to prevent concurrent duplicate checkout processing
997
+ const latched = await this.latchSessionProcessingAtomic(sessionID, md, contextUser);
998
+ if (!latched) {
999
+ return {
1000
+ Success: false,
1001
+ ErrorMessage: 'Session is not in an Open status or was concurrently processed',
1002
+ SessionID: sessionID,
1003
+ Status: session?.Status ?? 'Unknown'
1004
+ };
1005
+ }
1006
+ session.Status = 'Processing';
1007
+ // Set the moment order.Confirm() commits; controls the catch's no-revert posture.
1008
+ let confirmedOrderID = null;
1009
+ try {
1010
+ const widget = await md.GetEntityObject(CHECKOUT_WIDGET_ENTITY, contextUser);
1011
+ await widget.Load(session.CheckoutWidgetID);
1012
+ let linesInput = [];
1013
+ if (session.MetadataJSON) {
1014
+ try {
1015
+ const parsed = JSON.parse(session.MetadataJSON);
1016
+ if (parsed.Lines && Array.isArray(parsed.Lines)) {
1017
+ linesInput = parsed.Lines;
1018
+ }
1019
+ }
1020
+ catch {
1021
+ // Ignore metadata parse error
1022
+ }
1023
+ }
1024
+ // Resolve — creating if necessary — the payer Person. OrderHeaderEntity.Validate()
1025
+ // refuses to confirm an order without a customer, so a missing payer must fail
1026
+ // HERE with a clear message, not deep inside Confirm(). Contact fields come from
1027
+ // the first unit payload (name etc.); the email is the session's captured email.
1028
+ if (!session.PersonID) {
1029
+ const firstPayload = linesInput.length > 0 ? this.extractUnitPayloads(linesInput[0])[0] ?? {} : {};
1030
+ const payerFields = { ...firstPayload, Email: session.Email ?? '' };
1031
+ // A simple-product guest checkout collects only an email — derive a name from
1032
+ // the local part so the payer Person can be created. The GuestOrder identity
1033
+ // claim later re-parents the order to the buyer's real account identity.
1034
+ const hasName = payerFields['FirstName'] || payerFields['firstName'] || payerFields['LastName'] || payerFields['lastName'] || payerFields['Name'] || payerFields['name'] || payerFields['AttendeeName'];
1035
+ if (!hasName && session.Email) {
1036
+ const localPart = session.Email.split('@')[0]?.trim();
1037
+ if (localPart) {
1038
+ payerFields['Name'] = localPart;
1039
+ }
1040
+ }
1041
+ const payerPersonID = await this.resolveOrEnsurePerson(payerFields, contextUser, true);
1042
+ if (payerPersonID) {
1043
+ session.PersonID = payerPersonID;
1044
+ }
1045
+ }
1046
+ if (!session.PersonID) {
1047
+ await CheckoutSessionService.revertSessionOpenAtomic(sessionID, md, contextUser);
1048
+ session.Status = 'Open';
1049
+ return {
1050
+ Success: false,
1051
+ ErrorMessage: 'A buyer email (and name for a new customer) is required to complete checkout',
1052
+ SessionID: sessionID,
1053
+ Status: 'Open'
1054
+ };
1055
+ }
1056
+ // Atomically create the OrderHeaderEntity
1057
+ const order = await md.GetEntityObject(ORDER_HEADER_ENTITY, contextUser);
1058
+ order.NewRecord();
1059
+ order.CompanyID = widget.CompanyID;
1060
+ order.BillToPersonID = session.PersonID ?? null;
1061
+ order.ShipToPersonID = session.PersonID ?? null;
1062
+ order.Origin = 'Widget';
1063
+ order.OrderType = 'Sale';
1064
+ order.SourceCheckoutWidgetID = widget.ID;
1065
+ order.OrderDate = new Date();
1066
+ let sequence = 1;
1067
+ for (const inputLine of linesInput) {
1068
+ // Validate quantity: must be positive integer >= 1
1069
+ if (!Number.isInteger(inputLine.Quantity) || inputLine.Quantity < 1) {
1070
+ await CheckoutSessionService.revertSessionOpenAtomic(sessionID, md, contextUser);
1071
+ session.Status = 'Open';
1072
+ return {
1073
+ Success: false,
1074
+ ErrorMessage: `Invalid quantity: quantity must be a positive integer (received ${inputLine.Quantity})`,
1075
+ SessionID: sessionID,
1076
+ Status: 'Open'
1077
+ };
1078
+ }
1079
+ let targetExtensionEntity = null;
1080
+ let maxQuantityPerLine = null;
1081
+ let unitMode = 'perLine';
1082
+ if (inputLine.ProductID) {
1083
+ try {
1084
+ const product = await md.GetEntityObject(PRODUCT_ENTITY, contextUser);
1085
+ if (await product.Load(inputLine.ProductID)) {
1086
+ maxQuantityPerLine = product.MaxQuantityPerLine ?? null;
1087
+ if (product.ProductTypeID) {
1088
+ const pType = await md.GetEntityObject(PRODUCT_TYPE_ENTITY, contextUser);
1089
+ if (await pType.Load(product.ProductTypeID)) {
1090
+ targetExtensionEntity = pType.OrderLineExtensionEntity ?? null;
1091
+ if (pType.Configuration) {
1092
+ try {
1093
+ const pTypeConfig = JSON.parse(pType.Configuration);
1094
+ if (pTypeConfig.unitMode)
1095
+ unitMode = pTypeConfig.unitMode;
1096
+ if (pTypeConfig.maxQuantity && !maxQuantityPerLine)
1097
+ maxQuantityPerLine = pTypeConfig.maxQuantity;
1098
+ }
1099
+ catch {
1100
+ // Ignore malformed JSON
1101
+ }
1102
+ }
1103
+ }
1104
+ }
1105
+ }
1106
+ }
1107
+ catch {
1108
+ // Fallback
1109
+ }
1110
+ }
1111
+ const effectiveMax = maxQuantityPerLine ?? DEFAULT_MAX_QUANTITY_PER_LINE;
1112
+ if (inputLine.Quantity > effectiveMax) {
1113
+ await CheckoutSessionService.revertSessionOpenAtomic(sessionID, md, contextUser);
1114
+ session.Status = 'Open';
1115
+ return {
1116
+ Success: false,
1117
+ ErrorMessage: `Quantity ${inputLine.Quantity} exceeds maximum allowed quantity of ${effectiveMax} for this item`,
1118
+ SessionID: sessionID,
1119
+ Status: 'Open'
1120
+ };
1121
+ }
1122
+ const unitPayloads = this.extractUnitPayloads(inputLine);
1123
+ if (unitPayloads.length > effectiveMax) {
1124
+ await CheckoutSessionService.revertSessionOpenAtomic(sessionID, md, contextUser);
1125
+ session.Status = 'Open';
1126
+ return {
1127
+ Success: false,
1128
+ ErrorMessage: `Unit payload count ${unitPayloads.length} exceeds maximum allowed quantity of ${effectiveMax} for this item`,
1129
+ SessionID: sessionID,
1130
+ Status: 'Open'
1131
+ };
1132
+ }
1133
+ if ((unitMode === 'perUnit' || unitPayloads.length > 1) && unitPayloads.length > 0) {
1134
+ for (const unitData of unitPayloads) {
1135
+ const line = await this.createOrderLine(order, targetExtensionEntity, md, contextUser);
1136
+ line.ProductID = inputLine.ProductID;
1137
+ line.Quantity = 1;
1138
+ line.LineNumber = sequence++;
1139
+ await this.hydrateLineExtension(line, targetExtensionEntity, unitData, contextUser);
1140
+ }
1141
+ }
1142
+ else {
1143
+ const line = await this.createOrderLine(order, targetExtensionEntity, md, contextUser);
1144
+ line.ProductID = inputLine.ProductID;
1145
+ line.Quantity = inputLine.Quantity;
1146
+ line.LineNumber = sequence++;
1147
+ if (unitPayloads.length > 0) {
1148
+ await this.hydrateLineExtension(line, targetExtensionEntity, unitPayloads[0], contextUser);
1149
+ }
1150
+ }
1151
+ }
1152
+ // Price lines before confirmation
1153
+ const pricingService = new OrderPricingService({
1154
+ Provider: (order.ProviderToUse ?? md),
1155
+ User: contextUser ?? order.ContextCurrentUser,
1156
+ });
1157
+ await pricingService.Price({
1158
+ OrderHeaderID: order.ID || null,
1159
+ CompanyID: widget.CompanyID,
1160
+ BillToPersonID: order.BillToPersonID ?? null,
1161
+ BillToOrganizationID: order.BillToOrganizationID ?? null,
1162
+ OrderDate: order.OrderDate ?? new Date(),
1163
+ ShipToAddressID: order.ShipToAddressID ?? null,
1164
+ Lines: [...order.Lines.Items],
1165
+ PromotionCodes: [],
1166
+ ManualDiscounts: [],
1167
+ Charges: [],
1168
+ });
1169
+ let sumGross = 0;
1170
+ for (const line of order.Lines.Items) {
1171
+ const extPrice = line.LineTotalGross ?? ((line.UnitPrice ?? 0) * (line.Quantity ?? 1));
1172
+ sumGross += extPrice;
1173
+ }
1174
+ order.TotalGross = Math.round(sumGross * 100) / 100;
1175
+ // Money-path safety: a paid order confirms only against a payment intent whose
1176
+ // STATE and AMOUNT check out server-side. Mere existence of an intent id is not
1177
+ // payment — an opened-but-unpaid intent must never book an order. Intent status is
1178
+ // driven by the signature-verified payment webhook (PaymentWebhookHandler), never
1179
+ // by anything the client asserts.
1180
+ if (order.TotalGross > 0) {
1181
+ const paymentFailure = await this.verifySessionPayment(session, order.TotalGross, md, contextUser);
1182
+ if (paymentFailure) {
1183
+ await CheckoutSessionService.revertSessionOpenAtomic(sessionID, md, contextUser);
1184
+ session.Status = 'Open';
1185
+ return {
1186
+ Success: false,
1187
+ ErrorMessage: paymentFailure,
1188
+ SessionID: sessionID,
1189
+ Status: 'Open'
1190
+ };
1191
+ }
1192
+ }
1193
+ // Confirm order via BaseEntity lifecycle (executes GL booking, entitlement issuance, status latching)
1194
+ await order.Confirm();
1195
+ confirmedOrderID = order.ID;
1196
+ // The order is now COMMITTED — from this point nothing may revert the session to
1197
+ // Open, or a retry would book a second order for the same purchase. Session
1198
+ // stamping failures are logged and repaired atomically, never surfaced as a
1199
+ // checkout failure.
1200
+ session.Status = 'Confirmed';
1201
+ session.DraftOrderID = order.ID;
1202
+ const stamped = await session.Save();
1203
+ if (!stamped) {
1204
+ LogError(`[CheckoutSessionService] Session ${sessionID} completed order ${order.ID} but the entity save failed (${session.LatestResult?.CompleteMessage ?? 'unknown error'}) — falling back to atomic stamp`);
1205
+ await CheckoutSessionService.stampSessionConfirmedAtomic(sessionID, order.ID, md, contextUser);
1206
+ }
1207
+ // Book the PaymentHeader + allocation now that the order exists. retrieve-on-complete
1208
+ // only stamps PaymentIntent.Status = Succeeded; AmountPaid stays 0 until CapturePayment.
1209
+ await this.applySettledPaymentToOrder(session, order, contextUser);
1210
+ // Mint the GuestOrder identity claim for the buyer's email so a later account
1211
+ // (MJ core's IdentityClaimEngineServer) can attach the order + its entitlement
1212
+ // grants on redemption or claim-on-login. Best-effort: a claim failure must never
1213
+ // fail a booked order — but it is logged, never swallowed silently.
1214
+ let claimToken;
1215
+ if (session.Email) {
1216
+ try {
1217
+ const claim = await IdentityClaimEngineServer.Instance.CreateClaim({
1218
+ ClaimTypeName: 'GuestOrder',
1219
+ RecordID: order.ID,
1220
+ EntityID: md.EntityByName(ORDER_HEADER_ENTITY)?.ID ?? null,
1221
+ NormalizedEmail: session.Email,
1222
+ Payload: { OrderID: order.ID, OrderNumber: order.OrderNumber || order.ID },
1223
+ SendEmail: true
1224
+ }, contextUser);
1225
+ claimToken = claim?.ID;
1226
+ }
1227
+ catch (claimErr) {
1228
+ const claimMsg = claimErr instanceof Error ? claimErr.message : String(claimErr);
1229
+ LogError(`[CheckoutSessionService] GuestOrder claim minting failed for order ${order.ID} (${session.Email}): ${claimMsg}`);
1230
+ }
1231
+ }
1232
+ return {
1233
+ Success: true,
1234
+ OrderID: order.ID,
1235
+ OrderNumber: order.OrderNumber || order.ID,
1236
+ TotalGross: order.TotalGross ?? sumGross,
1237
+ SessionID: sessionID,
1238
+ Status: 'Confirmed',
1239
+ ClaimToken: claimToken
1240
+ };
1241
+ }
1242
+ catch (err) {
1243
+ const msg = err instanceof Error ? err.message : String(err);
1244
+ if (confirmedOrderID) {
1245
+ // Post-commit failure: the order EXISTS. Do not revert the latch (a retry would
1246
+ // double-book); stamp the session Confirmed atomically and report success.
1247
+ LogError(`[CheckoutSessionService] CompleteCheckout post-confirm failure for session ${sessionID} (order ${confirmedOrderID}): ${msg}`);
1248
+ await CheckoutSessionService.stampSessionConfirmedAtomic(sessionID, confirmedOrderID, md, contextUser);
1249
+ return {
1250
+ Success: true,
1251
+ OrderID: confirmedOrderID,
1252
+ SessionID: sessionID,
1253
+ Status: 'Confirmed'
1254
+ };
1255
+ }
1256
+ await CheckoutSessionService.revertSessionOpenAtomic(sessionID, md, contextUser);
1257
+ session.Status = 'Open';
1258
+ LogError(`[CheckoutSessionService] CompleteCheckout failed for session ${sessionID}: ${msg}`);
1259
+ return {
1260
+ Success: false,
1261
+ ErrorMessage: `Failed to complete checkout: ${msg}`,
1262
+ SessionID: sessionID,
1263
+ Status: 'Open'
1264
+ };
1265
+ }
1266
+ }
1267
+ /**
1268
+ * Verifies the paid-order gate for a session against a server-computed total. Returns a
1269
+ * refusal message, or null when payment checks out. The intent must exist, belong to this
1270
+ * session, be in a settled state, and cover the freshly re-priced total.
1271
+ */
1272
+ static async verifySessionPayment(session, totalGross, md, contextUser) {
1273
+ if (!session.PaymentIntentID) {
1274
+ return 'Cannot confirm paid order (TotalGross > 0) without a payment intent for this session';
1275
+ }
1276
+ const intent = await md.GetEntityObject(PAYMENT_INTENT_ENTITY, contextUser);
1277
+ const intentLoaded = await intent.Load(session.PaymentIntentID);
1278
+ if (!intentLoaded) {
1279
+ return 'The payment intent attached to this session could not be found';
1280
+ }
1281
+ if (!SETTLED_INTENT_STATUSES.includes(intent.Status)) {
1282
+ // The browser has already confirmCardPayment'd (4242 succeeds at Stripe immediately).
1283
+ // The signature-verified webhook is what normally stamps Succeeded, but it often
1284
+ // has not landed yet — especially on localhost, which Stripe cannot POST to.
1285
+ // Retrieve from the gateway with OUR key (never a client claim) and adopt that status.
1286
+ await this.refreshIntentFromGateway(intent, contextUser);
1287
+ }
1288
+ if (!SETTLED_INTENT_STATUSES.includes(intent.Status)) {
1289
+ return `Payment has not settled (intent status: ${intent.Status}). Complete payment and try again.`;
1290
+ }
1291
+ // Half-cent tolerance absorbs decimal rounding between the priced total and the
1292
+ // cents-rounded intent amount.
1293
+ if ((intent.Amount ?? 0) + 0.005 < totalGross) {
1294
+ return `The settled payment amount (${intent.Amount}) does not cover the order total (${totalGross})`;
1295
+ }
1296
+ return null;
1297
+ }
1298
+ /**
1299
+ * Gateway-side retry for a checkout whose order confirmed but CapturePayment did not book.
1300
+ *
1301
+ * `applyEvent` only stamps PaymentIntent status — it does not write a PaymentHeader (D19).
1302
+ * Card checkout books via CapturePayment after Confirm. If that fails, two things retry
1303
+ * the same idempotent CapturePayment (`checkout-complete:${session.ID}`):
1304
+ * 1. POST /checkout/complete against a Confirmed session (client replay)
1305
+ * 2. This method, from `payment_intent.succeeded` — including AlreadyApplied deliveries,
1306
+ * so a 500 after the intent row is stamped still converges on the next Stripe retry.
1307
+ *
1308
+ * No-ops when the intent is not a confirmed checkout session (back-office captures).
1309
+ */
1310
+ static async BookSettledCheckoutPaymentIfNeeded(paymentIntentID, contextUser) {
1311
+ if (!paymentIntentID) {
1312
+ return { Attempted: false, Booked: false };
1313
+ }
1314
+ const rv = new RunView();
1315
+ const found = await rv.RunView({
1316
+ EntityName: CHECKOUT_SESSION_ENTITY,
1317
+ ExtraFilter: `PaymentIntentID = '${EscapeText(paymentIntentID)}' AND Status = 'Confirmed'`,
1318
+ MaxRows: 1,
1319
+ ResultType: 'simple',
1320
+ }, contextUser);
1321
+ const sessionID = found?.Results?.[0]?.ID;
1322
+ if (!sessionID) {
1323
+ return { Attempted: false, Booked: false };
1324
+ }
1325
+ const md = new Metadata();
1326
+ const session = await md.GetEntityObject(CHECKOUT_SESSION_ENTITY, contextUser);
1327
+ if (!(await session.Load(sessionID)) || !session.DraftOrderID) {
1328
+ return { Attempted: false, Booked: false };
1329
+ }
1330
+ const order = await md.GetEntityObject(ORDER_HEADER_ENTITY, contextUser);
1331
+ if (!(await order.Load(session.DraftOrderID))) {
1332
+ return { Attempted: false, Booked: false };
1333
+ }
1334
+ return this.applySettledPaymentToOrder(session, order, contextUser);
1335
+ }
1336
+ /**
1337
+ * After the order is committed, book `Orders.CapturePayment` against the session's
1338
+ * settled PaymentIntent and stamp PaymentIntent.OrderHeaderID.
1339
+ *
1340
+ * Fail-soft and post-commit: a capture problem must not un-confirm the order.
1341
+ * Retries are CompleteCheckout replay and `BookSettledCheckoutPaymentIfNeeded`
1342
+ * from the payment_intent.succeeded webhook (including AlreadyApplied).
1343
+ */
1344
+ static async applySettledPaymentToOrder(session, order, contextUser) {
1345
+ if (!session.PaymentIntentID) {
1346
+ return { Attempted: false, Booked: false };
1347
+ }
1348
+ const due = Number(order.TotalGross ?? 0);
1349
+ if (!(due > 0)) {
1350
+ return { Attempted: false, Booked: false };
1351
+ }
1352
+ if (Number(order.AmountPaid ?? 0) + 0.005 >= due) {
1353
+ return { Attempted: false, Booked: true };
1354
+ }
1355
+ if (!contextUser) {
1356
+ return this.captureFailed(order.ID, 'no context user', undefined, undefined, session.ID);
1357
+ }
1358
+ if (!order.BillToPersonID && !order.BillToOrganizationID) {
1359
+ return this.captureFailed(order.ID, 'no bill-to party', undefined, contextUser, session.ID);
1360
+ }
1361
+ const md = new Metadata();
1362
+ const intent = await md.GetEntityObject(PAYMENT_INTENT_ENTITY, contextUser);
1363
+ if (!(await intent.Load(session.PaymentIntentID))) {
1364
+ return this.captureFailed(order.ID, 'payment intent not found', undefined, contextUser, session.ID);
1365
+ }
1366
+ if (!SETTLED_INTENT_STATUSES.includes(intent.Status)) {
1367
+ await this.refreshIntentFromGateway(intent, contextUser);
1368
+ }
1369
+ if (!SETTLED_INTENT_STATUSES.includes(intent.Status)) {
1370
+ return this.captureFailed(order.ID, `intent status is ${intent.Status}`, undefined, contextUser, session.ID);
1371
+ }
1372
+ if (!intent.OrderHeaderID) {
1373
+ intent.OrderHeaderID = order.ID;
1374
+ }
1375
+ if (!intent.BillToPersonID && order.BillToPersonID) {
1376
+ intent.BillToPersonID = order.BillToPersonID;
1377
+ }
1378
+ if (!intent.BillToOrganizationID && order.BillToOrganizationID) {
1379
+ intent.BillToOrganizationID = order.BillToOrganizationID;
1380
+ }
1381
+ if (!(await intent.Save())) {
1382
+ LogError(`[CheckoutSessionService] Could not stamp OrderHeaderID on intent ${intent.ID} for order ${order.ID}: ${intent.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1383
+ }
1384
+ let tenderCode = 'CreditCard';
1385
+ try {
1386
+ const widget = await md.GetEntityObject(CHECKOUT_WIDGET_ENTITY, contextUser);
1387
+ if (await widget.Load(session.CheckoutWidgetID) && widget.Configuration) {
1388
+ const configObj = JSON.parse(widget.Configuration);
1389
+ if (typeof configObj.tenderCode === 'string' && configObj.tenderCode.trim()) {
1390
+ tenderCode = configObj.tenderCode.trim();
1391
+ }
1392
+ }
1393
+ }
1394
+ catch {
1395
+ // Keep the CreditCard default; CapturePayment will refuse an unknown code.
1396
+ }
1397
+ const mdProvider = Metadata.Provider;
1398
+ if (!mdProvider) {
1399
+ return this.captureFailed(order.ID, 'no metadata provider', undefined, contextUser, session.ID);
1400
+ }
1401
+ try {
1402
+ const op = new CapturePaymentOperation();
1403
+ const result = await op.ExecuteServer({
1404
+ PaymentIntentID: intent.ID,
1405
+ Amount: due,
1406
+ ReceivingCompanyID: order.CompanyID,
1407
+ BillToPersonID: order.BillToPersonID ?? null,
1408
+ BillToOrganizationID: order.BillToOrganizationID ?? null,
1409
+ TenderCode: tenderCode,
1410
+ Allocations: [{ OrderHeaderID: order.ID, Amount: due }],
1411
+ PaymentDetail: intent.PaymentProviderID
1412
+ ? { PaymentProviderID: intent.PaymentProviderID }
1413
+ : null,
1414
+ IdempotencyKey: `checkout-complete:${session.ID}`,
1415
+ }, {
1416
+ provider: mdProvider,
1417
+ user: contextUser,
1418
+ emitProgress: () => undefined,
1419
+ });
1420
+ const output = result.Output;
1421
+ if (!result.Success || !output?.Success) {
1422
+ const detail = result.ErrorMessage
1423
+ ?? output?.Message
1424
+ ?? output?.Blockers?.map((b) => b.Message).join('; ')
1425
+ ?? 'unknown error';
1426
+ const codes = (output?.Blockers ?? []).map((b) => b.Code).filter((c) => Boolean(c));
1427
+ return this.captureFailed(order.ID, detail, isCaptureRefusalRetryable(codes), contextUser, session.ID);
1428
+ }
1429
+ return { Attempted: true, Booked: true };
1430
+ }
1431
+ catch (err) {
1432
+ const msg = err instanceof Error ? err.message : String(err);
1433
+ return this.captureFailed(order.ID, msg, true, contextUser, session.ID);
1434
+ }
1435
+ }
1436
+ static captureFailed(orderID, message, retryable, contextUser, sessionID) {
1437
+ const Retryable = retryable ?? !isTerminalCapturePrecheck(message);
1438
+ LogError(`[CheckoutSessionService] CapturePayment failed for order ${orderID}: ${message}`);
1439
+ if (!Retryable) {
1440
+ void raiseCheckoutCaptureTerminalAlert(orderID, sessionID, message, contextUser);
1441
+ }
1442
+ return { Attempted: true, Booked: false, ErrorMessage: message, Retryable };
1443
+ }
1444
+ /**
1445
+ * Pull the gateway's current intent status onto our row. Fail-soft: a retrieve
1446
+ * error leaves the local status unchanged and the paid gate still refuses.
1447
+ */
1448
+ static async refreshIntentFromGateway(intent, contextUser) {
1449
+ if (!intent.PaymentProviderID || !intent.ProviderIntentID || !contextUser) {
1450
+ return;
1451
+ }
1452
+ const mdProvider = Metadata.Provider;
1453
+ if (!mdProvider) {
1454
+ return;
1455
+ }
1456
+ try {
1457
+ const driver = await ResolvePaymentProvider(intent.PaymentProviderID, mdProvider, contextUser);
1458
+ const retrieved = await driver.RetrieveIntent({ ProviderIntentID: intent.ProviderIntentID });
1459
+ if (!retrieved.Success || !retrieved.Status) {
1460
+ return;
1461
+ }
1462
+ if (retrieved.Status === intent.Status && retrieved.Amount == null) {
1463
+ return;
1464
+ }
1465
+ intent.Status = retrieved.Status;
1466
+ if (typeof retrieved.Amount === 'number' && retrieved.Amount > 0) {
1467
+ intent.Amount = retrieved.Amount;
1468
+ }
1469
+ if (!(await intent.Save())) {
1470
+ LogError(`[CheckoutSessionService] Could not stamp retrieved intent status ${retrieved.Status} on ${intent.ID}`);
1471
+ }
1472
+ }
1473
+ catch (err) {
1474
+ LogError(`[CheckoutSessionService] Gateway retrieve for intent ${intent.ID} failed: ${err instanceof Error ? err.message : String(err)}`);
1475
+ }
1476
+ }
1477
+ /**
1478
+ * Atomically stamps a Processing session as Confirmed with its completed order id — the
1479
+ * repair path when the entity-level save cannot run after the order has committed.
1480
+ */
1481
+ static async stampSessionConfirmedAtomic(sessionID, orderID, md, contextUser) {
1482
+ try {
1483
+ const provider = (Metadata.Provider || Metadata.Provider);
1484
+ if (!provider || typeof provider.ExecuteSQL !== 'function') {
1485
+ return false;
1486
+ }
1487
+ const entityInfo = md.Entities?.find(e => e.Name === CHECKOUT_SESSION_ENTITY);
1488
+ const schemaName = entityInfo?.SchemaName ?? '__mj_BizAppsOrders';
1489
+ const tableName = entityInfo?.BaseTable ?? 'CheckoutSession';
1490
+ const isPg = provider.PlatformKey === 'postgresql';
1491
+ const table = isPg ? `${schemaName}.${tableName}` : `[${schemaName}].[${tableName}]`;
1492
+ const sql = isPg
1493
+ ? `UPDATE ${table} SET "Status" = 'Confirmed', "DraftOrderID" = $2 WHERE "ID" = $1 AND "Status" = 'Processing' RETURNING "ID";`
1494
+ : `DECLARE @stamped TABLE (ID UNIQUEIDENTIFIER); UPDATE ${table} SET [Status] = 'Confirmed', [DraftOrderID] = @p1 OUTPUT INSERTED.ID INTO @stamped WHERE [ID] = @p0 AND [Status] = 'Processing'; SELECT ID FROM @stamped;`;
1495
+ const rows = await provider.ExecuteSQL(sql, [sessionID, orderID], { isMutation: true }, contextUser);
1496
+ return Array.isArray(rows) && rows.length === 1;
1497
+ }
1498
+ catch (err) {
1499
+ const msg = err instanceof Error ? err.message : String(err);
1500
+ LogError(`[CheckoutSessionService] stampSessionConfirmedAtomic failed for session ${sessionID}: ${msg}`);
1501
+ return false;
1502
+ }
1503
+ }
1504
+ /**
1505
+ * Executes atomic single-use Compare-And-Swap (CAS) state transition on CheckoutSession
1506
+ * from 'Open' to 'Processing'. Returns true iff this execution successfully transitioned the record.
1507
+ */
1508
+ static async latchSessionProcessingAtomic(sessionID, md, contextUser) {
1509
+ try {
1510
+ const provider = (Metadata.Provider || Metadata.Provider);
1511
+ if (!provider || typeof provider.ExecuteSQL !== 'function') {
1512
+ LogError('latchSessionProcessingAtomic: provider or ExecuteSQL not available for atomic CAS');
1513
+ return false;
1514
+ }
1515
+ const entityInfo = md.Entities?.find(e => e.Name === CHECKOUT_SESSION_ENTITY);
1516
+ const schemaName = entityInfo?.SchemaName ?? '__mj_BizAppsOrders';
1517
+ const tableName = entityInfo?.BaseTable ?? 'CheckoutSession';
1518
+ const isPg = provider.PlatformKey === 'postgresql';
1519
+ const table = isPg ? `${schemaName}.${tableName}` : `[${schemaName}].[${tableName}]`;
1520
+ const sql = isPg
1521
+ ? `UPDATE ${table} SET "Status" = 'Processing' WHERE "ID" = $1 AND "Status" = 'Open' RETURNING "ID";`
1522
+ : `DECLARE @latched TABLE (ID UNIQUEIDENTIFIER); UPDATE ${table} SET [Status] = 'Processing' OUTPUT INSERTED.ID INTO @latched WHERE [ID] = @p0 AND [Status] = 'Open'; SELECT ID FROM @latched;`;
1523
+ const rows = await provider.ExecuteSQL(sql, [sessionID], { isMutation: true }, contextUser);
1524
+ return Array.isArray(rows) && rows.length === 1;
1525
+ }
1526
+ catch {
1527
+ return false;
1528
+ }
1529
+ }
1530
+ /**
1531
+ * Reverts atomic CAS state from 'Processing' back to 'Open' if order confirmation fails.
1532
+ */
1533
+ static async revertSessionOpenAtomic(sessionID, md, contextUser) {
1534
+ try {
1535
+ const provider = (Metadata.Provider || Metadata.Provider);
1536
+ if (!provider || typeof provider.ExecuteSQL !== 'function') {
1537
+ return false;
1538
+ }
1539
+ const entityInfo = md.Entities?.find(e => e.Name === CHECKOUT_SESSION_ENTITY);
1540
+ const schemaName = entityInfo?.SchemaName ?? '__mj_BizAppsOrders';
1541
+ const tableName = entityInfo?.BaseTable ?? 'CheckoutSession';
1542
+ const isPg = provider.PlatformKey === 'postgresql';
1543
+ const table = isPg ? `${schemaName}.${tableName}` : `[${schemaName}].[${tableName}]`;
1544
+ const sql = isPg
1545
+ ? `UPDATE ${table} SET "Status" = 'Open' WHERE "ID" = $1 AND "Status" = 'Processing' RETURNING "ID";`
1546
+ : `DECLARE @reverted TABLE (ID UNIQUEIDENTIFIER); UPDATE ${table} SET [Status] = 'Open' OUTPUT INSERTED.ID INTO @reverted WHERE [ID] = @p0 AND [Status] = 'Processing'; SELECT ID FROM @reverted;`;
1547
+ const rows = await provider.ExecuteSQL(sql, [sessionID], { isMutation: true }, contextUser);
1548
+ return Array.isArray(rows) && rows.length === 1;
1549
+ }
1550
+ catch (err) {
1551
+ const msg = err instanceof Error ? err.message : String(err);
1552
+ LogError(`[CheckoutSessionService] revertSessionOpenAtomic failed for session ${sessionID}: ${msg}`);
1553
+ return false;
1554
+ }
1555
+ }
1556
+ }
1557
+ //# sourceMappingURL=CheckoutSessionService.js.map