@aura-labs-ai/scout 0.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 +237 -0
- package/bin/scout-cli.js +325 -0
- package/examples/basic-purchase.js +112 -0
- package/examples/protocol-integration.js +294 -0
- package/package.json +63 -0
- package/src/activity.js +420 -0
- package/src/ap2/mandates.js +366 -0
- package/src/client.js +217 -0
- package/src/errors.js +66 -0
- package/src/index.js +655 -0
- package/src/intent-session.js +233 -0
- package/src/key-manager.js +204 -0
- package/src/key-manager.test.js +293 -0
- package/src/mcp/client.js +458 -0
- package/src/session.js +603 -0
- package/src/tap/visa.js +345 -0
- package/src/tests/README.md +157 -0
- package/src/tests/ap2-mandates.test.js +413 -0
- package/src/tests/intent-session.test.js +292 -0
- package/src/tests/mcp-client.test.js +224 -0
- package/src/tests/ping.test.js +282 -0
- package/src/tests/run-protocol-tests.js +57 -0
- package/src/tests/scenarios/ap2-scenarios.test.js +422 -0
- package/src/tests/scenarios/index.js +60 -0
- package/src/tests/scenarios/integration-scenarios.test.js +514 -0
- package/src/tests/scenarios/mcp-scenarios.test.js +419 -0
- package/src/tests/scenarios/tap-scenarios.test.js +461 -0
- package/src/tests/visa-tap.test.js +376 -0
package/src/session.js
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scout Session
|
|
3
|
+
*
|
|
4
|
+
* Represents an active commerce session with AURA Core.
|
|
5
|
+
* Handles offer polling, constraint evaluation, and transaction commitment.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { SessionError, OfferError, ConstraintError } from './errors.js';
|
|
9
|
+
import { ScoutActivityEventTypes } from './activity.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Session states
|
|
13
|
+
*/
|
|
14
|
+
export const SessionStatus = {
|
|
15
|
+
CREATED: 'created',
|
|
16
|
+
MARKET_FORMING: 'market_forming',
|
|
17
|
+
OFFERS_AVAILABLE: 'offers_available',
|
|
18
|
+
NEGOTIATING: 'negotiating',
|
|
19
|
+
COMMITTED: 'committed',
|
|
20
|
+
COMPLETED: 'completed',
|
|
21
|
+
CANCELLED: 'cancelled',
|
|
22
|
+
EXPIRED: 'expired',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Commerce Session
|
|
27
|
+
*/
|
|
28
|
+
export class Session {
|
|
29
|
+
#data;
|
|
30
|
+
#client;
|
|
31
|
+
#config;
|
|
32
|
+
#offers = [];
|
|
33
|
+
#constraints;
|
|
34
|
+
#pollInterval = null;
|
|
35
|
+
#activityLogger = null;
|
|
36
|
+
|
|
37
|
+
constructor(data, client, config) {
|
|
38
|
+
this.#data = data;
|
|
39
|
+
this.#client = client;
|
|
40
|
+
this.#config = config;
|
|
41
|
+
this.#constraints = new Constraints(config.constraints || {});
|
|
42
|
+
this.#activityLogger = config.activityLogger || null;
|
|
43
|
+
|
|
44
|
+
if (this.#activityLogger) {
|
|
45
|
+
this.#activityLogger.record(ScoutActivityEventTypes.SESSION_CREATED, {
|
|
46
|
+
sessionId: this.id,
|
|
47
|
+
metadata: {
|
|
48
|
+
status: this.status,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Session ID
|
|
56
|
+
*/
|
|
57
|
+
get id() {
|
|
58
|
+
return this.#data.sessionId;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Current session status
|
|
63
|
+
*/
|
|
64
|
+
get status() {
|
|
65
|
+
return this.#data.status;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parsed intent (as interpreted by Core)
|
|
70
|
+
*/
|
|
71
|
+
get intent() {
|
|
72
|
+
return this.#data.intent;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Available HATEOAS links
|
|
77
|
+
*/
|
|
78
|
+
get links() {
|
|
79
|
+
return this.#data._links || {};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Is session still active?
|
|
84
|
+
*/
|
|
85
|
+
get isActive() {
|
|
86
|
+
return ![
|
|
87
|
+
SessionStatus.COMPLETED,
|
|
88
|
+
SessionStatus.CANCELLED,
|
|
89
|
+
SessionStatus.EXPIRED,
|
|
90
|
+
].includes(this.status);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get current offers
|
|
95
|
+
*/
|
|
96
|
+
get offers() {
|
|
97
|
+
return this.#offers;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Refresh session state from Core
|
|
102
|
+
*/
|
|
103
|
+
async refresh() {
|
|
104
|
+
this.#data = await this.#client.get(`/sessions/${this.id}`);
|
|
105
|
+
|
|
106
|
+
if (this.#activityLogger) {
|
|
107
|
+
this.#activityLogger.record(ScoutActivityEventTypes.SESSION_REFRESHED, {
|
|
108
|
+
sessionId: this.id,
|
|
109
|
+
metadata: {
|
|
110
|
+
status: this.status,
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return this;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Poll for offers until available or timeout
|
|
120
|
+
*
|
|
121
|
+
* @param {object} options
|
|
122
|
+
* @param {number} options.timeout - Max time to wait in ms (default: 30000)
|
|
123
|
+
* @param {number} options.interval - Poll interval in ms (default: 2000)
|
|
124
|
+
* @returns {Promise<Offer[]>}
|
|
125
|
+
*/
|
|
126
|
+
async waitForOffers(options = {}) {
|
|
127
|
+
const { timeout = 30000, interval = 2000 } = options;
|
|
128
|
+
const startTime = Date.now();
|
|
129
|
+
|
|
130
|
+
const finish = this.#activityLogger?.startTimer(ScoutActivityEventTypes.WAIT_FOR_OFFERS, {
|
|
131
|
+
sessionId: this.id,
|
|
132
|
+
metadata: { timeout, interval },
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
while (Date.now() - startTime < timeout) {
|
|
137
|
+
await this.refresh();
|
|
138
|
+
|
|
139
|
+
if (this.status === SessionStatus.OFFERS_AVAILABLE) {
|
|
140
|
+
this.#offers = await this.#fetchOffers();
|
|
141
|
+
|
|
142
|
+
finish?.({
|
|
143
|
+
success: true,
|
|
144
|
+
metadata: {
|
|
145
|
+
offersCount: this.#offers.length,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return this.#offers;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!this.isActive) {
|
|
153
|
+
throw new SessionError(`Session is no longer active: ${this.status}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await this.#sleep(interval);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
throw new SessionError('Timed out waiting for offers', 'TIMEOUT');
|
|
160
|
+
} catch (error) {
|
|
161
|
+
this.#activityLogger?.record(ScoutActivityEventTypes.WAIT_FOR_OFFERS_FAILED, {
|
|
162
|
+
sessionId: this.id,
|
|
163
|
+
success: false,
|
|
164
|
+
error: error.message,
|
|
165
|
+
});
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Fetch current offers from Core
|
|
172
|
+
*/
|
|
173
|
+
async #fetchOffers() {
|
|
174
|
+
if (!this.links.offers) {
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const response = await this.#client.get(`/sessions/${this.id}/offers`);
|
|
179
|
+
return (response.offers || []).map(o => new Offer(o, this.#constraints));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Get offers that satisfy all constraints
|
|
184
|
+
*/
|
|
185
|
+
get validOffers() {
|
|
186
|
+
return this.#offers.filter(o => o.meetsConstraints);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Get best offer based on constraint ranking
|
|
191
|
+
*/
|
|
192
|
+
get bestOffer() {
|
|
193
|
+
const valid = this.validOffers;
|
|
194
|
+
if (valid.length === 0) return null;
|
|
195
|
+
|
|
196
|
+
return valid.reduce((best, offer) =>
|
|
197
|
+
offer.score > best.score ? offer : best
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Commit to an offer
|
|
203
|
+
*
|
|
204
|
+
* @param {string} offerId - ID of the offer to commit to
|
|
205
|
+
* @returns {Promise<Transaction>}
|
|
206
|
+
*/
|
|
207
|
+
async commit(offerId) {
|
|
208
|
+
const offer = this.#offers.find(o => o.id === offerId);
|
|
209
|
+
|
|
210
|
+
if (!offer) {
|
|
211
|
+
throw new OfferError(`Offer not found: ${offerId}`, offerId);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!offer.meetsConstraints) {
|
|
215
|
+
const violations = offer.constraintViolations;
|
|
216
|
+
throw new ConstraintError(
|
|
217
|
+
`Offer violates constraints: ${violations.join(', ')}`,
|
|
218
|
+
violations[0]
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const finish = this.#activityLogger?.startTimer(ScoutActivityEventTypes.SESSION_COMMITTED, {
|
|
223
|
+
sessionId: this.id,
|
|
224
|
+
metadata: {
|
|
225
|
+
offerId,
|
|
226
|
+
offerPrice: offer.totalPrice,
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const response = await this.#client.post(`/sessions/${this.id}/commit`, {
|
|
232
|
+
offerId,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
this.#data.status = SessionStatus.COMMITTED;
|
|
236
|
+
|
|
237
|
+
const transaction = new Transaction(response, this.#client, this.#activityLogger);
|
|
238
|
+
|
|
239
|
+
finish?.({
|
|
240
|
+
success: true,
|
|
241
|
+
metadata: {
|
|
242
|
+
transactionId: transaction.id,
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return transaction;
|
|
247
|
+
} catch (error) {
|
|
248
|
+
this.#activityLogger?.record(ScoutActivityEventTypes.SESSION_COMMIT_FAILED, {
|
|
249
|
+
sessionId: this.id,
|
|
250
|
+
success: false,
|
|
251
|
+
error: error.message,
|
|
252
|
+
metadata: { offerId },
|
|
253
|
+
});
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Cancel the session
|
|
260
|
+
*/
|
|
261
|
+
async cancel() {
|
|
262
|
+
try {
|
|
263
|
+
await this.#client.post(`/sessions/${this.id}/cancel`);
|
|
264
|
+
this.#data.status = SessionStatus.CANCELLED;
|
|
265
|
+
|
|
266
|
+
this.#activityLogger?.record(ScoutActivityEventTypes.SESSION_CANCELLED, {
|
|
267
|
+
sessionId: this.id,
|
|
268
|
+
success: true,
|
|
269
|
+
});
|
|
270
|
+
} catch (error) {
|
|
271
|
+
this.#activityLogger?.record(ScoutActivityEventTypes.SESSION_CANCEL_FAILED, {
|
|
272
|
+
sessionId: this.id,
|
|
273
|
+
success: false,
|
|
274
|
+
error: error.message,
|
|
275
|
+
});
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Update constraints for this session
|
|
282
|
+
*/
|
|
283
|
+
updateConstraints(constraints) {
|
|
284
|
+
this.#constraints = new Constraints({
|
|
285
|
+
...this.#constraints.raw,
|
|
286
|
+
...constraints,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
#sleep(ms) {
|
|
291
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Convert to JSON for serialization
|
|
296
|
+
*/
|
|
297
|
+
toJSON() {
|
|
298
|
+
return {
|
|
299
|
+
id: this.id,
|
|
300
|
+
status: this.status,
|
|
301
|
+
intent: this.intent,
|
|
302
|
+
offers: this.#offers.map(o => o.toJSON()),
|
|
303
|
+
links: this.links,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Constraints Engine
|
|
310
|
+
*
|
|
311
|
+
* Evaluates offers against user-defined constraints.
|
|
312
|
+
*/
|
|
313
|
+
export class Constraints {
|
|
314
|
+
#constraints;
|
|
315
|
+
|
|
316
|
+
constructor(constraints = {}) {
|
|
317
|
+
this.#constraints = {
|
|
318
|
+
maxBudget: constraints.maxBudget || null,
|
|
319
|
+
deliveryBy: constraints.deliveryBy ? new Date(constraints.deliveryBy) : null,
|
|
320
|
+
hardConstraints: constraints.hardConstraints || [],
|
|
321
|
+
softPreferences: constraints.softPreferences || [],
|
|
322
|
+
...constraints,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
get raw() {
|
|
327
|
+
return { ...this.#constraints };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Check if offer meets all hard constraints
|
|
332
|
+
*/
|
|
333
|
+
meetsConstraints(offer) {
|
|
334
|
+
return this.getViolations(offer).length === 0;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Get list of constraint violations
|
|
339
|
+
*/
|
|
340
|
+
getViolations(offer) {
|
|
341
|
+
const violations = [];
|
|
342
|
+
|
|
343
|
+
// Check budget
|
|
344
|
+
if (this.#constraints.maxBudget !== null) {
|
|
345
|
+
if (offer.totalPrice > this.#constraints.maxBudget) {
|
|
346
|
+
violations.push(`price_exceeds_budget:${offer.totalPrice}>${this.#constraints.maxBudget}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Check delivery date
|
|
351
|
+
if (this.#constraints.deliveryBy !== null) {
|
|
352
|
+
if (offer.deliveryDate && new Date(offer.deliveryDate) > this.#constraints.deliveryBy) {
|
|
353
|
+
violations.push(`delivery_too_late:${offer.deliveryDate}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Check custom hard constraints
|
|
358
|
+
for (const constraint of this.#constraints.hardConstraints) {
|
|
359
|
+
if (!this.#evaluateConstraint(constraint, offer)) {
|
|
360
|
+
violations.push(constraint.name || constraint.field);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return violations;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Score offer based on soft preferences (0-100)
|
|
369
|
+
*/
|
|
370
|
+
score(offer) {
|
|
371
|
+
let score = 50; // Base score
|
|
372
|
+
|
|
373
|
+
// Budget utilization (prefer lower prices)
|
|
374
|
+
if (this.#constraints.maxBudget && offer.totalPrice) {
|
|
375
|
+
const utilizationRatio = offer.totalPrice / this.#constraints.maxBudget;
|
|
376
|
+
score += (1 - utilizationRatio) * 20; // Up to +20 for lower prices
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Delivery speed (prefer earlier delivery)
|
|
380
|
+
if (this.#constraints.deliveryBy && offer.deliveryDate) {
|
|
381
|
+
const deliveryDate = new Date(offer.deliveryDate);
|
|
382
|
+
const deadline = this.#constraints.deliveryBy;
|
|
383
|
+
const now = new Date();
|
|
384
|
+
const totalWindow = deadline - now;
|
|
385
|
+
const actualWindow = deliveryDate - now;
|
|
386
|
+
|
|
387
|
+
if (totalWindow > 0 && actualWindow < totalWindow) {
|
|
388
|
+
score += ((totalWindow - actualWindow) / totalWindow) * 15; // Up to +15 for faster delivery
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Soft preferences
|
|
393
|
+
for (const pref of this.#constraints.softPreferences) {
|
|
394
|
+
if (this.#evaluateConstraint(pref, offer)) {
|
|
395
|
+
score += pref.weight || 5;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Clamp to 0-100
|
|
400
|
+
return Math.max(0, Math.min(100, score));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// SECURITY: Operator allowlist — unknown operators fail closed (reject, not accept)
|
|
404
|
+
static #ALLOWED_OPERATORS = new Set(['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'contains', 'in']);
|
|
405
|
+
|
|
406
|
+
#evaluateConstraint(constraint, offer) {
|
|
407
|
+
// Validate constraint has required fields
|
|
408
|
+
if (!constraint.field || typeof constraint.field !== 'string') {
|
|
409
|
+
return false; // Malformed constraint → fail closed
|
|
410
|
+
}
|
|
411
|
+
if (!constraint.operator || !Constraints.#ALLOWED_OPERATORS.has(constraint.operator)) {
|
|
412
|
+
return false; // Unknown/missing operator → fail closed
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const value = offer[constraint.field];
|
|
416
|
+
|
|
417
|
+
switch (constraint.operator) {
|
|
418
|
+
case 'eq': return value === constraint.value;
|
|
419
|
+
case 'ne': return value !== constraint.value;
|
|
420
|
+
case 'gt': return value > constraint.value;
|
|
421
|
+
case 'gte': return value >= constraint.value;
|
|
422
|
+
case 'lt': return value < constraint.value;
|
|
423
|
+
case 'lte': return value <= constraint.value;
|
|
424
|
+
case 'contains': return String(value).includes(String(constraint.value));
|
|
425
|
+
case 'in': return Array.isArray(constraint.value) && constraint.value.includes(value);
|
|
426
|
+
default: return false; // Unreachable, but fail closed
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Offer from a Beacon
|
|
433
|
+
*/
|
|
434
|
+
export class Offer {
|
|
435
|
+
#data;
|
|
436
|
+
#constraints;
|
|
437
|
+
|
|
438
|
+
constructor(data, constraints) {
|
|
439
|
+
this.#data = data;
|
|
440
|
+
this.#constraints = constraints;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
get id() { return this.#data.id; }
|
|
444
|
+
get beaconId() { return this.#data.beaconId; }
|
|
445
|
+
get beaconName() { return this.#data.beaconName; }
|
|
446
|
+
get product() { return this.#data.product; }
|
|
447
|
+
get unitPrice() { return this.#data.unitPrice; }
|
|
448
|
+
get quantity() { return this.#data.quantity; }
|
|
449
|
+
get totalPrice() { return this.#data.totalPrice || (this.unitPrice * this.quantity); }
|
|
450
|
+
get currency() { return this.#data.currency || 'USD'; }
|
|
451
|
+
get deliveryDate() { return this.#data.deliveryDate; }
|
|
452
|
+
get terms() { return this.#data.terms || {}; }
|
|
453
|
+
get metadata() { return this.#data.metadata || {}; }
|
|
454
|
+
|
|
455
|
+
get meetsConstraints() {
|
|
456
|
+
return this.#constraints.meetsConstraints(this.#data);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
get constraintViolations() {
|
|
460
|
+
return this.#constraints.getViolations(this.#data);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
get score() {
|
|
464
|
+
return this.#constraints.score(this.#data);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
toJSON() {
|
|
468
|
+
return {
|
|
469
|
+
...this.#data,
|
|
470
|
+
meetsConstraints: this.meetsConstraints,
|
|
471
|
+
constraintViolations: this.constraintViolations,
|
|
472
|
+
score: this.score,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Committed Transaction
|
|
479
|
+
*/
|
|
480
|
+
export class Transaction {
|
|
481
|
+
#data;
|
|
482
|
+
#client;
|
|
483
|
+
#activityLogger;
|
|
484
|
+
|
|
485
|
+
constructor(data, client, activityLogger = null) {
|
|
486
|
+
this.#data = data;
|
|
487
|
+
this.#client = client;
|
|
488
|
+
this.#activityLogger = activityLogger;
|
|
489
|
+
|
|
490
|
+
if (this.#activityLogger) {
|
|
491
|
+
this.#activityLogger.record(ScoutActivityEventTypes.TRANSACTION_CREATED, {
|
|
492
|
+
transactionId: this.id,
|
|
493
|
+
metadata: {
|
|
494
|
+
status: this.status,
|
|
495
|
+
paymentStatus: this.paymentStatus,
|
|
496
|
+
fulfillmentStatus: this.fulfillmentStatus,
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
get id() { return this.#data.transactionId; }
|
|
503
|
+
get status() { return this.#data.status; }
|
|
504
|
+
get offerId() { return this.#data.offerId; }
|
|
505
|
+
get paymentStatus() { return this.#data.paymentStatus; }
|
|
506
|
+
get fulfillmentStatus() { return this.#data.fulfillmentStatus; }
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Refresh transaction state from Core
|
|
510
|
+
*
|
|
511
|
+
* @returns {Promise<Transaction>} Returns this for chaining
|
|
512
|
+
*/
|
|
513
|
+
async refresh() {
|
|
514
|
+
this.#data = await this.#client.get(`/transactions/${this.id}`);
|
|
515
|
+
|
|
516
|
+
if (this.#activityLogger) {
|
|
517
|
+
this.#activityLogger.record(ScoutActivityEventTypes.TRANSACTION_REFRESHED, {
|
|
518
|
+
transactionId: this.id,
|
|
519
|
+
metadata: {
|
|
520
|
+
status: this.status,
|
|
521
|
+
paymentStatus: this.paymentStatus,
|
|
522
|
+
fulfillmentStatus: this.fulfillmentStatus,
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return this;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Wait for transaction fulfillment
|
|
532
|
+
*
|
|
533
|
+
* @param {object} options
|
|
534
|
+
* @param {number} options.timeout - Max time to wait in ms (default: 300000 / 5 min)
|
|
535
|
+
* @param {number} options.interval - Poll interval in ms (default: 5000)
|
|
536
|
+
* @returns {Promise<Transaction>} Returns this when fulfilled
|
|
537
|
+
* @throws {Error} If fulfillment fails or timeout exceeded
|
|
538
|
+
*/
|
|
539
|
+
async waitForFulfillment(options = {}) {
|
|
540
|
+
const { timeout = 300000, interval = 5000 } = options;
|
|
541
|
+
const startTime = Date.now();
|
|
542
|
+
|
|
543
|
+
const finish = this.#activityLogger?.startTimer(ScoutActivityEventTypes.WAIT_FOR_FULFILLMENT, {
|
|
544
|
+
transactionId: this.id,
|
|
545
|
+
metadata: { timeout, interval },
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
try {
|
|
549
|
+
while (Date.now() - startTime < timeout) {
|
|
550
|
+
await this.refresh();
|
|
551
|
+
|
|
552
|
+
if (this.fulfillmentStatus === 'delivered') {
|
|
553
|
+
finish?.({
|
|
554
|
+
success: true,
|
|
555
|
+
metadata: {
|
|
556
|
+
fulfillmentStatus: this.fulfillmentStatus,
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
return this;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (this.fulfillmentStatus === 'failed') {
|
|
563
|
+
throw new Error(`Transaction fulfillment failed: ${this.id}`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
await this.#sleep(interval);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
throw new Error(`Timed out waiting for transaction fulfillment: ${this.id}`);
|
|
570
|
+
} catch (error) {
|
|
571
|
+
this.#activityLogger?.record(ScoutActivityEventTypes.WAIT_FOR_FULFILLMENT_FAILED, {
|
|
572
|
+
transactionId: this.id,
|
|
573
|
+
success: false,
|
|
574
|
+
error: error.message,
|
|
575
|
+
metadata: {
|
|
576
|
+
fulfillmentStatus: this.fulfillmentStatus,
|
|
577
|
+
},
|
|
578
|
+
});
|
|
579
|
+
throw error;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Get beacon information
|
|
585
|
+
*/
|
|
586
|
+
get beacon() {
|
|
587
|
+
return {
|
|
588
|
+
id: this.#data.beaconId,
|
|
589
|
+
name: this.#data.beaconName,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Convert to JSON for serialization
|
|
595
|
+
*/
|
|
596
|
+
toJSON() {
|
|
597
|
+
return { ...this.#data };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
#sleep(ms) {
|
|
601
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
602
|
+
}
|
|
603
|
+
}
|