@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.
- package/README.md +3 -1
- package/dist/AdvanceOrderStateOperation.d.ts.map +1 -1
- package/dist/AdvanceOrderStateOperation.js +35 -50
- package/dist/AdvanceOrderStateOperation.js.map +1 -1
- package/dist/BasePaymentProvider.d.ts +24 -0
- package/dist/BasePaymentProvider.d.ts.map +1 -1
- package/dist/BasePaymentProvider.js +10 -0
- package/dist/BasePaymentProvider.js.map +1 -1
- package/dist/CancelSubscriptionOperation.d.ts +9 -1
- package/dist/CancelSubscriptionOperation.d.ts.map +1 -1
- package/dist/CancelSubscriptionOperation.js +15 -3
- package/dist/CancelSubscriptionOperation.js.map +1 -1
- package/dist/CheckEntitlementOperation.d.ts +27 -0
- package/dist/CheckEntitlementOperation.d.ts.map +1 -0
- package/dist/CheckEntitlementOperation.js +45 -0
- package/dist/CheckEntitlementOperation.js.map +1 -0
- package/dist/CheckoutSessionService.d.ts +258 -0
- package/dist/CheckoutSessionService.d.ts.map +1 -0
- package/dist/CheckoutSessionService.js +1557 -0
- package/dist/CheckoutSessionService.js.map +1 -0
- package/dist/EntitlementBehavior.d.ts +68 -0
- package/dist/EntitlementBehavior.d.ts.map +1 -1
- package/dist/EntitlementBehavior.js +126 -0
- package/dist/EntitlementBehavior.js.map +1 -1
- package/dist/EntitlementEngine.d.ts +11 -0
- package/dist/EntitlementEngine.d.ts.map +1 -1
- package/dist/EntitlementEngine.js +38 -1
- package/dist/EntitlementEngine.js.map +1 -1
- package/dist/EntitlementGrantClaimDriver.d.ts +42 -0
- package/dist/EntitlementGrantClaimDriver.d.ts.map +1 -0
- package/dist/EntitlementGrantClaimDriver.js +158 -0
- package/dist/EntitlementGrantClaimDriver.js.map +1 -0
- package/dist/EntitlementRead.d.ts +82 -0
- package/dist/EntitlementRead.d.ts.map +1 -0
- package/dist/EntitlementRead.js +368 -0
- package/dist/EntitlementRead.js.map +1 -0
- package/dist/FulfillOrderLinesOperation.d.ts.map +1 -1
- package/dist/FulfillOrderLinesOperation.js +6 -12
- package/dist/FulfillOrderLinesOperation.js.map +1 -1
- package/dist/FulfillmentBehavior.js +2 -2
- package/dist/FulfillmentBehavior.js.map +1 -1
- package/dist/GetFulfillmentQueueOperation.d.ts.map +1 -1
- package/dist/GetFulfillmentQueueOperation.js +2 -4
- package/dist/GetFulfillmentQueueOperation.js.map +1 -1
- package/dist/GuestOrderClaimDriver.d.ts +36 -0
- package/dist/GuestOrderClaimDriver.d.ts.map +1 -0
- package/dist/GuestOrderClaimDriver.js +161 -0
- package/dist/GuestOrderClaimDriver.js.map +1 -0
- package/dist/ListEntitlementsOperation.d.ts +21 -0
- package/dist/ListEntitlementsOperation.d.ts.map +1 -0
- package/dist/ListEntitlementsOperation.js +39 -0
- package/dist/ListEntitlementsOperation.js.map +1 -0
- package/dist/OrderEntityServer.d.ts.map +1 -1
- package/dist/OrderEntityServer.js +2 -5
- package/dist/OrderEntityServer.js.map +1 -1
- package/dist/OrdersSettings.d.ts +5 -0
- package/dist/OrdersSettings.d.ts.map +1 -1
- package/dist/OrdersSettings.js +46 -5
- package/dist/OrdersSettings.js.map +1 -1
- package/dist/PaymentWebhookHandler.d.ts.map +1 -1
- package/dist/PaymentWebhookHandler.js +52 -3
- package/dist/PaymentWebhookHandler.js.map +1 -1
- package/dist/PreviewPriceOperation.d.ts +10 -17
- package/dist/PreviewPriceOperation.d.ts.map +1 -1
- package/dist/PreviewPriceOperation.js +40 -28
- package/dist/PreviewPriceOperation.js.map +1 -1
- package/dist/PriceOrderOperation.d.ts +5 -8
- package/dist/PriceOrderOperation.d.ts.map +1 -1
- package/dist/PriceOrderOperation.js +5 -8
- package/dist/PriceOrderOperation.js.map +1 -1
- package/dist/StripePaymentProvider.d.ts +13 -1
- package/dist/StripePaymentProvider.d.ts.map +1 -1
- package/dist/StripePaymentProvider.js +73 -15
- package/dist/StripePaymentProvider.js.map +1 -1
- package/dist/checkoutCaptureAlert.d.ts +16 -0
- package/dist/checkoutCaptureAlert.d.ts.map +1 -0
- package/dist/checkoutCaptureAlert.js +55 -0
- package/dist/checkoutCaptureAlert.js.map +1 -0
- package/dist/checkoutCaptureRetry.d.ts +27 -0
- package/dist/checkoutCaptureRetry.d.ts.map +1 -0
- package/dist/checkoutCaptureRetry.js +50 -0
- package/dist/checkoutCaptureRetry.js.map +1 -0
- package/dist/claimDriverHelpers.d.ts +15 -0
- package/dist/claimDriverHelpers.d.ts.map +1 -0
- package/dist/claimDriverHelpers.js +34 -0
- package/dist/claimDriverHelpers.js.map +1 -0
- package/dist/identityClaimContracts.d.ts +118 -0
- package/dist/identityClaimContracts.d.ts.map +1 -0
- package/dist/identityClaimContracts.js +58 -0
- package/dist/identityClaimContracts.js.map +1 -0
- package/dist/index.d.ts +17 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -3
- package/dist/index.js.map +1 -1
- package/dist/sql-guards.d.ts +16 -0
- package/dist/sql-guards.d.ts.map +1 -1
- package/dist/sql-guards.js +21 -0
- package/dist/sql-guards.js.map +1 -1
- 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
|