@comfyorg/account-core 1.0.0-alpha.0 → 1.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/billing/billingContracts.d.ts +29 -14
- package/dist/core/billing/billingErrorBody.d.ts +8 -4
- package/dist/core/billing/billingErrorBody.js +14 -4
- package/dist/core/billing/credentialedTransport.d.ts +23 -7
- package/dist/core/billing/credentialedTransport.js +72 -8
- package/dist/core/billing/events.d.ts +47 -0
- package/dist/core/billing/events.js +43 -0
- package/dist/core/billing/index.d.ts +16 -11
- package/dist/core/billing/index.js +8 -6
- package/dist/core/billing/operationLifecycle.d.ts +34 -1
- package/dist/core/billing/operationLifecycle.js +146 -67
- package/dist/core/billing/operationPointer.d.ts +27 -2
- package/dist/core/billing/operationPointer.js +36 -25
- package/dist/core/billing/operationPolicy.d.ts +44 -7
- package/dist/core/billing/operationPolicy.js +64 -3
- package/dist/core/billing/operationState.d.ts +102 -18
- package/dist/core/billing/operationState.js +78 -8
- package/dist/core/billing/paymentCopy.d.ts +6 -1
- package/dist/core/billing/paymentCopy.js +34 -1
- package/dist/core/billing/paymentProjection.d.ts +14 -2
- package/dist/core/billing/paymentProjection.js +37 -9
- package/dist/core/billing/scopedReader.d.ts +11 -5
- package/dist/core/billing/scopedReader.js +20 -9
- package/dist/core/billing/sharedRead.js +4 -2
- package/dist/core/billing/subscriptionCommands.d.ts +37 -9
- package/dist/core/billing/subscriptionCommands.js +42 -30
- package/dist/core/billing/topup.d.ts +26 -0
- package/dist/core/billing/topup.js +36 -2
- package/dist/core/billing/wireCents.d.ts +8 -0
- package/dist/core/billing/wireCents.js +8 -0
- package/dist/core/billing/workspaceInvites.d.ts +12 -0
- package/dist/core/billing/workspaceInvites.js +26 -0
- package/dist/core/identity.d.ts +6 -6
- package/dist/core/identity.js +4 -10
- package/dist/core/lazyIdentity.d.ts +31 -0
- package/dist/core/lazyIdentity.js +96 -0
- package/dist/core/requestAuth.d.ts +30 -0
- package/dist/core/requestAuth.js +41 -0
- package/dist/core/session.d.ts +12 -18
- package/dist/core/session.js +16 -26
- package/dist/core/sessionContracts.d.ts +39 -2
- package/dist/core/sessionTokenMint.d.ts +33 -0
- package/dist/core/sessionTokenMint.js +205 -0
- package/dist/core/webSession.d.ts +25 -0
- package/dist/core/webSession.js +137 -0
- package/dist/core/webSessionFlag.d.ts +20 -0
- package/dist/core/webSessionFlag.js +62 -0
- package/dist/core/webSessionIdentity.d.ts +291 -0
- package/dist/core/webSessionIdentity.js +496 -0
- package/dist/firebase/configSource.d.ts +54 -0
- package/dist/firebase/configSource.js +111 -0
- package/dist/firebase/index.d.ts +71 -8
- package/dist/firebase/index.js +236 -23
- package/dist/firebase/popupWatch.d.ts +42 -0
- package/dist/firebase/popupWatch.js +140 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.js +116 -0
- package/dist/web/crossTabRefresh.d.ts +4 -2
- package/dist/web/crossTabRefresh.js +13 -0
- package/dist/workspaceLink.d.ts +45 -0
- package/dist/workspaceLink.js +78 -0
- package/package.json +30 -6
|
@@ -16,14 +16,17 @@
|
|
|
16
16
|
* wiring onto the scope source, the scope tracker, and the generated
|
|
17
17
|
* contract is new.
|
|
18
18
|
*/
|
|
19
|
-
import { zBillingOpStatusResponse } from '@comfyorg/ingest-types/zod';
|
|
20
19
|
import { BILLING_OPERATION_TELEMETRY_EVENT } from '../../telemetry.js';
|
|
21
20
|
import { createBillingScopeTracker } from './billingScope.js';
|
|
22
21
|
import { NO_POINTER_STORE, createOperationPointerStore } from './operationPointer.js';
|
|
23
|
-
import { hasExhaustedPollBudget, nextPollDelayMs } from './operationPolicy.js';
|
|
24
|
-
import { isTerminal, reduceBillingOperation, validateActionUrl } from './operationState.js';
|
|
22
|
+
import { hasExhaustedPollBudget, isWaitingOnCustomerWithoutAction, nextPollDelayMs } from './operationPolicy.js';
|
|
23
|
+
import { BillingOpStatusSchema, isGrantLanding, isTerminal, reduceBillingOperation, validateActionUrl } from './operationState.js';
|
|
25
24
|
import { selectBillingPresentation } from './presentation.js';
|
|
26
25
|
import { readValidatedBillingResponse } from './sharedRead.js';
|
|
26
|
+
/** Settled, but a later read may still change what the operation reports. */
|
|
27
|
+
function isStillSettling(state) {
|
|
28
|
+
return state.phase === 'reconciliation_needed' || isGrantLanding(state);
|
|
29
|
+
}
|
|
27
30
|
export function operationRoute(operationId) {
|
|
28
31
|
return `/billing/ops/${encodeURIComponent(operationId)}`;
|
|
29
32
|
}
|
|
@@ -35,6 +38,10 @@ const SUPERSEDED = {
|
|
|
35
38
|
status: 'error',
|
|
36
39
|
code: 'SUPERSEDED'
|
|
37
40
|
};
|
|
41
|
+
const OPERATION_ALREADY_PENDING = {
|
|
42
|
+
status: 'error',
|
|
43
|
+
code: 'OPERATION_ALREADY_PENDING'
|
|
44
|
+
};
|
|
38
45
|
function continuationOf(issued) {
|
|
39
46
|
return {
|
|
40
47
|
...(issued.actionUrl === undefined ? {} : { actionUrl: issued.actionUrl }),
|
|
@@ -67,6 +74,7 @@ function initialPendingState(input, observedAt, presentation) {
|
|
|
67
74
|
...presentation,
|
|
68
75
|
observedAt,
|
|
69
76
|
attemptStartedAt: input.attemptStartedAt,
|
|
77
|
+
...(input.awaitedHere ? { awaitedHere: true } : {}),
|
|
70
78
|
phase: 'pending',
|
|
71
79
|
...(actionUrl === undefined ? {} : { actionUrl }),
|
|
72
80
|
...(challenge === undefined ? {} : { challenge }),
|
|
@@ -96,7 +104,9 @@ export function createBillingOperationLifecycle(options) {
|
|
|
96
104
|
const { transport, scopeSource, statusReader, embeddedCheckoutAvailable = () => false, hostedDestination = () => 'stripe', onTelemetry, now = Date.now } = options;
|
|
97
105
|
const pointers = options.pointerStorage === undefined
|
|
98
106
|
? NO_POINTER_STORE
|
|
99
|
-
: createOperationPointerStore(options.pointerStorage, now
|
|
107
|
+
: createOperationPointerStore(options.pointerStorage, now, {
|
|
108
|
+
retainSettled: options.retainSettledPointer === true
|
|
109
|
+
});
|
|
100
110
|
const operations = new Map();
|
|
101
111
|
const inFlightCommands = new Map();
|
|
102
112
|
const listeners = new Set();
|
|
@@ -114,7 +124,7 @@ export function createBillingOperationLifecycle(options) {
|
|
|
114
124
|
return !lifetime.disposed && scopeTracker.isCurrent(context);
|
|
115
125
|
}
|
|
116
126
|
function publish(record) {
|
|
117
|
-
for (const listener of
|
|
127
|
+
for (const listener of Array.from(listeners))
|
|
118
128
|
listener(record.state);
|
|
119
129
|
}
|
|
120
130
|
function stopTimer(record) {
|
|
@@ -152,7 +162,7 @@ export function createBillingOperationLifecycle(options) {
|
|
|
152
162
|
record.state = next;
|
|
153
163
|
if (isTerminal(next)) {
|
|
154
164
|
stopTimer(record);
|
|
155
|
-
pointers.
|
|
165
|
+
pointers.settle(next);
|
|
156
166
|
emitTerminalTelemetry(record);
|
|
157
167
|
record.resolveSettled(next);
|
|
158
168
|
}
|
|
@@ -162,7 +172,12 @@ export function createBillingOperationLifecycle(options) {
|
|
|
162
172
|
if (record.state.phase !== 'pending')
|
|
163
173
|
return;
|
|
164
174
|
stopTimer(record);
|
|
165
|
-
|
|
175
|
+
record.waitingWithoutActionSince = isWaitingOnCustomerWithoutAction(record.state)
|
|
176
|
+
? (record.waitingWithoutActionSince ?? now())
|
|
177
|
+
: undefined;
|
|
178
|
+
const delayMs = nextPollDelayMs(record.state, record.delayMs, record.waitingWithoutActionSince === undefined
|
|
179
|
+
? 0
|
|
180
|
+
: now() - record.waitingWithoutActionSince);
|
|
166
181
|
record.delayMs = delayMs;
|
|
167
182
|
record.timer = setTimeout(() => void poll(record), delayMs);
|
|
168
183
|
}
|
|
@@ -185,11 +200,12 @@ export function createBillingOperationLifecycle(options) {
|
|
|
185
200
|
operationId: state.id,
|
|
186
201
|
kind: state.kind,
|
|
187
202
|
presentation: state.presentation,
|
|
188
|
-
attemptStartedAt: state.attemptStartedAt
|
|
203
|
+
attemptStartedAt: state.attemptStartedAt,
|
|
204
|
+
...(state.awaitedHere ? { awaited: true } : {})
|
|
189
205
|
});
|
|
190
206
|
}
|
|
191
207
|
function readOperation(operationId) {
|
|
192
|
-
return readValidatedBillingResponse(transport, { method: 'GET', route: operationRoute(operationId) }, (body) =>
|
|
208
|
+
return readValidatedBillingResponse(transport, { method: 'GET', route: operationRoute(operationId) }, (body) => BillingOpStatusSchema.safeParse(body));
|
|
193
209
|
}
|
|
194
210
|
function continueOrExpire(record) {
|
|
195
211
|
if (record.state.phase !== 'pending')
|
|
@@ -248,11 +264,19 @@ export function createBillingOperationLifecycle(options) {
|
|
|
248
264
|
? { presentation, hostedDestination: hostedDestination() }
|
|
249
265
|
: { presentation };
|
|
250
266
|
}
|
|
251
|
-
|
|
267
|
+
/**
|
|
268
|
+
* An id is observed afresh once this tab has stopped learning anything new
|
|
269
|
+
* about it: its poll budget ran out or it left the scope. One the server
|
|
270
|
+
* parked for reconciliation, or a success whose credits were still landing,
|
|
271
|
+
* is observed afresh only for a caller reading settled operations, since
|
|
272
|
+
* the server may since have settled it or recorded the grant.
|
|
273
|
+
*/
|
|
274
|
+
function adopt(input, includeSettled = false) {
|
|
252
275
|
const existing = operations.get(input.id);
|
|
253
276
|
if (existing !== undefined &&
|
|
254
277
|
existing.state.phase !== 'timed_out' &&
|
|
255
|
-
existing.state.phase !== 'superseded'
|
|
278
|
+
existing.state.phase !== 'superseded' &&
|
|
279
|
+
!(includeSettled && isStillSettling(existing.state))) {
|
|
256
280
|
return existing;
|
|
257
281
|
}
|
|
258
282
|
const state = initialPendingState(input, now(), destinationFor(input.presentation));
|
|
@@ -265,6 +289,7 @@ export function createBillingOperationLifecycle(options) {
|
|
|
265
289
|
context: input.context,
|
|
266
290
|
resumed: input.resumed,
|
|
267
291
|
delayMs: undefined,
|
|
292
|
+
waitingWithoutActionSince: undefined,
|
|
268
293
|
timer: undefined,
|
|
269
294
|
inFlightPoll: undefined,
|
|
270
295
|
settled,
|
|
@@ -279,15 +304,22 @@ export function createBillingOperationLifecycle(options) {
|
|
|
279
304
|
presentation: input.presentation,
|
|
280
305
|
resumed: input.resumed
|
|
281
306
|
});
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
307
|
+
startObserving(record, input, state);
|
|
308
|
+
return record;
|
|
309
|
+
}
|
|
310
|
+
// A resumed operation without a served link is announced by its first
|
|
311
|
+
// status, never before it: the status read names it but not what it waits
|
|
312
|
+
// on, and a checkout parked on a card must not be announced as processing
|
|
313
|
+
// for the length of a poll. A served link already says what it waits on.
|
|
314
|
+
function startObserving(record, input, state) {
|
|
315
|
+
if (input.initialStatus !== undefined) {
|
|
287
316
|
dispatch(record, { type: 'status_polled', status: input.initialStatus });
|
|
288
317
|
continueOrExpire(record);
|
|
318
|
+
return;
|
|
289
319
|
}
|
|
290
|
-
|
|
320
|
+
if (!input.resumed || state.actionUrl !== undefined)
|
|
321
|
+
publish(record);
|
|
322
|
+
void poll(record);
|
|
291
323
|
}
|
|
292
324
|
function routeFor(rail, issued) {
|
|
293
325
|
return selectBillingPresentation({
|
|
@@ -311,34 +343,21 @@ export function createBillingOperationLifecycle(options) {
|
|
|
311
343
|
if (status.status === 'error')
|
|
312
344
|
return status;
|
|
313
345
|
const rail = status.value.status.billing_rail;
|
|
314
|
-
const
|
|
315
|
-
if (
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
presentation: routeFor(rail, pending),
|
|
320
|
-
attemptStartedAt: now(),
|
|
321
|
-
resumed: true
|
|
322
|
-
});
|
|
323
|
-
return { status: 'ok', value: record.state };
|
|
324
|
-
}
|
|
346
|
+
const parked = await resubmitTarget(pendingFromStatus(status.value.status), kind);
|
|
347
|
+
if (!isLive(context))
|
|
348
|
+
return SUPERSEDED;
|
|
349
|
+
if (parked === 'refused')
|
|
350
|
+
return OPERATION_ALREADY_PENDING;
|
|
325
351
|
const attemptStartedAt = now();
|
|
326
352
|
const issued = await issue(context.scope);
|
|
327
353
|
if (!isLive(context)) {
|
|
328
|
-
|
|
329
|
-
// the pointer waits there so a return recovers it rather than reissuing.
|
|
330
|
-
if (issued.status === 'ok') {
|
|
331
|
-
pointers.write(context.scope, {
|
|
332
|
-
operationId: issued.value.operationId,
|
|
333
|
-
kind,
|
|
334
|
-
presentation: routeFor(rail, issued.value),
|
|
335
|
-
attemptStartedAt
|
|
336
|
-
});
|
|
337
|
-
}
|
|
338
|
-
return SUPERSEDED;
|
|
354
|
+
return leftScope(context, kind, rail, issued, attemptStartedAt);
|
|
339
355
|
}
|
|
340
356
|
if (issued.status === 'error')
|
|
341
357
|
return issued;
|
|
358
|
+
const resumed = parked === undefined ? undefined : resumeParked(parked, issued.value);
|
|
359
|
+
if (resumed !== undefined)
|
|
360
|
+
return { status: 'ok', value: resumed };
|
|
342
361
|
const record = adopt({
|
|
343
362
|
id: issued.value.operationId,
|
|
344
363
|
kind,
|
|
@@ -346,10 +365,66 @@ export function createBillingOperationLifecycle(options) {
|
|
|
346
365
|
presentation: routeFor(rail, issued.value),
|
|
347
366
|
...continuationOf(issued.value),
|
|
348
367
|
attemptStartedAt,
|
|
349
|
-
resumed: false
|
|
368
|
+
resumed: false,
|
|
369
|
+
awaitedHere: true
|
|
350
370
|
});
|
|
351
371
|
return { status: 'ok', value: record.state };
|
|
352
372
|
}
|
|
373
|
+
// The operation exists server-side under the scope this tab just left; the
|
|
374
|
+
// pointer waits there so a return recovers it rather than reissuing.
|
|
375
|
+
function leftScope(context, kind, rail, issued, attemptStartedAt) {
|
|
376
|
+
if (issued.status === 'ok') {
|
|
377
|
+
pointers.write(context.scope, {
|
|
378
|
+
operationId: issued.value.operationId,
|
|
379
|
+
kind,
|
|
380
|
+
presentation: routeFor(rail, issued.value),
|
|
381
|
+
attemptStartedAt,
|
|
382
|
+
awaited: true
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
return SUPERSEDED;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Declines rather than joins an operation of this kind the server already
|
|
389
|
+
* has pending, because the status names no plan: this caller asked for one
|
|
390
|
+
* outcome and the parked attempt settles another, so reporting that one as
|
|
391
|
+
* this command's result would tell the customer they bought something they
|
|
392
|
+
* did not choose. recover() is where a deliberate return to it belongs.
|
|
393
|
+
*
|
|
394
|
+
* The exception is a checkout this tab watches parked on a card. The server
|
|
395
|
+
* keeps no link back to it, and a resubmit is how it resumes that checkout
|
|
396
|
+
* or replaces it, so the command goes through with that record in hand.
|
|
397
|
+
*/
|
|
398
|
+
async function resubmitTarget(pending, kind) {
|
|
399
|
+
if (pending?.kind !== kind)
|
|
400
|
+
return undefined;
|
|
401
|
+
const record = operations.get(pending.id);
|
|
402
|
+
if (record?.state.phase === 'pending' &&
|
|
403
|
+
record.state.serverPhase === undefined) {
|
|
404
|
+
const read = await readOperation(pending.id);
|
|
405
|
+
if (read.status === 'ok') {
|
|
406
|
+
dispatch(record, { type: 'status_polled', status: read.value.data });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return record?.state.phase === 'pending' &&
|
|
410
|
+
record.state.serverPhase === 'awaiting_payment_method'
|
|
411
|
+
? record
|
|
412
|
+
: 'refused';
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* The same id is the parked checkout resumed, with the fresh hosted step the
|
|
416
|
+
* server minted for it; another id replaced it, so this tab stops watching.
|
|
417
|
+
*/
|
|
418
|
+
function resumeParked(parked, issued) {
|
|
419
|
+
if (parked.state.id !== issued.operationId) {
|
|
420
|
+
dispatch(parked, { type: 'superseded' });
|
|
421
|
+
return undefined;
|
|
422
|
+
}
|
|
423
|
+
if (issued.actionUrl !== undefined) {
|
|
424
|
+
dispatch(parked, { type: 'action_reissued', actionUrl: issued.actionUrl });
|
|
425
|
+
}
|
|
426
|
+
return parked.state;
|
|
427
|
+
}
|
|
353
428
|
function begin(kind, issue) {
|
|
354
429
|
if (lifetime.disposed)
|
|
355
430
|
return Promise.resolve(SUPERSEDED);
|
|
@@ -370,38 +445,43 @@ export function createBillingOperationLifecycle(options) {
|
|
|
370
445
|
inFlightCommands.set(kind, { context, promise: attempt });
|
|
371
446
|
return attempt;
|
|
372
447
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
function recoverFromPointer(failure, pointer, context) {
|
|
376
|
-
if (failure.code !== 'REQUEST_FAILED' || pointer === undefined) {
|
|
377
|
-
return failure;
|
|
378
|
-
}
|
|
379
|
-
const record = adopt({
|
|
448
|
+
function fromPointer(pointer, context) {
|
|
449
|
+
return {
|
|
380
450
|
id: pointer.operationId,
|
|
381
451
|
kind: pointer.kind,
|
|
382
452
|
context,
|
|
383
453
|
presentation: pointer.presentation,
|
|
384
454
|
attemptStartedAt: pointer.attemptStartedAt,
|
|
385
|
-
resumed: true
|
|
386
|
-
|
|
455
|
+
resumed: true,
|
|
456
|
+
awaitedHere: pointer.awaited === true
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
// Unreachable is not "nothing pending": the pointer is the only evidence
|
|
460
|
+
// left, and observing it costs a poll while reissuing could cost a charge.
|
|
461
|
+
function recoverFromPointer(failure, pointer, context, includeSettled) {
|
|
462
|
+
if (failure.code !== 'REQUEST_FAILED' || pointer === undefined) {
|
|
463
|
+
return failure;
|
|
464
|
+
}
|
|
465
|
+
const record = adopt(fromPointer(pointer, context), includeSettled);
|
|
387
466
|
return { status: 'ok', value: record.state };
|
|
388
467
|
}
|
|
389
|
-
function resumeServerPending(pending, rail, pointer, context) {
|
|
468
|
+
function resumeServerPending(pending, rail, pointer, context, includeSettled) {
|
|
390
469
|
const known = pointer?.operationId === pending.id ? pointer : undefined;
|
|
391
470
|
const record = adopt({
|
|
392
471
|
...pending,
|
|
393
472
|
context,
|
|
394
473
|
presentation: known?.presentation ?? routeFor(rail, pending),
|
|
395
474
|
attemptStartedAt: known?.attemptStartedAt ?? now(),
|
|
396
|
-
resumed: true
|
|
397
|
-
|
|
475
|
+
resumed: true,
|
|
476
|
+
awaitedHere: known?.awaited === true
|
|
477
|
+
}, includeSettled);
|
|
398
478
|
return { status: 'ok', value: record.state };
|
|
399
479
|
}
|
|
400
480
|
// The server reports nothing pending, so the pointed-at operation has
|
|
401
481
|
// either settled since this tab last saw it or never belonged to this
|
|
402
482
|
// scope. One read decides which; a stale pointer is dropped, never
|
|
403
483
|
// re-observed on a schedule.
|
|
404
|
-
async function probePointer(pointer, context) {
|
|
484
|
+
async function probePointer(pointer, context, includeSettled) {
|
|
405
485
|
const probe = await readOperation(pointer.operationId);
|
|
406
486
|
if (!isLive(context))
|
|
407
487
|
return SUPERSEDED;
|
|
@@ -411,18 +491,17 @@ export function createBillingOperationLifecycle(options) {
|
|
|
411
491
|
pointers.clear(context.scope, pointer.operationId);
|
|
412
492
|
return { status: 'ok', value: undefined };
|
|
413
493
|
}
|
|
414
|
-
const record = adopt({
|
|
415
|
-
id: pointer.operationId,
|
|
416
|
-
kind: pointer.kind,
|
|
417
|
-
context,
|
|
418
|
-
presentation: pointer.presentation,
|
|
419
|
-
attemptStartedAt: pointer.attemptStartedAt,
|
|
420
|
-
resumed: true,
|
|
421
|
-
initialStatus: probe.value.data
|
|
422
|
-
});
|
|
494
|
+
const record = adopt({ ...fromPointer(pointer, context), initialStatus: probe.value.data }, includeSettled);
|
|
423
495
|
return { status: 'ok', value: record.state };
|
|
424
496
|
}
|
|
425
|
-
|
|
497
|
+
/** A settled pointer answers only a caller that asked for it. */
|
|
498
|
+
function readPointer(scope, includeSettled) {
|
|
499
|
+
const pointer = pointers.read(scope);
|
|
500
|
+
return pointer?.settled === undefined || includeSettled
|
|
501
|
+
? pointer
|
|
502
|
+
: undefined;
|
|
503
|
+
}
|
|
504
|
+
async function recover({ includeSettled = false } = {}) {
|
|
426
505
|
if (lifetime.disposed)
|
|
427
506
|
return SUPERSEDED;
|
|
428
507
|
const context = scopeTracker.capture();
|
|
@@ -431,17 +510,17 @@ export function createBillingOperationLifecycle(options) {
|
|
|
431
510
|
const status = await statusReader.read();
|
|
432
511
|
if (!isLive(context))
|
|
433
512
|
return SUPERSEDED;
|
|
434
|
-
const pointer =
|
|
513
|
+
const pointer = readPointer(context.scope, includeSettled);
|
|
435
514
|
if (status.status === 'error') {
|
|
436
|
-
return recoverFromPointer(status, pointer, context);
|
|
515
|
+
return recoverFromPointer(status, pointer, context, includeSettled);
|
|
437
516
|
}
|
|
438
517
|
const pending = pendingFromStatus(status.value.status);
|
|
439
518
|
if (pending !== undefined) {
|
|
440
|
-
return resumeServerPending(pending, status.value.status.billing_rail, pointer, context);
|
|
519
|
+
return resumeServerPending(pending, status.value.status.billing_rail, pointer, context, includeSettled);
|
|
441
520
|
}
|
|
442
521
|
if (pointer === undefined)
|
|
443
522
|
return { status: 'ok', value: undefined };
|
|
444
|
-
return probePointer(pointer, context);
|
|
523
|
+
return probePointer(pointer, context, includeSettled);
|
|
445
524
|
}
|
|
446
525
|
function wake() {
|
|
447
526
|
for (const record of operations.values()) {
|
|
@@ -22,16 +22,24 @@ declare const PointerSchema: z.ZodObject<{
|
|
|
22
22
|
kind: z.ZodEnum<["subscription", "topup", "cancel"]>;
|
|
23
23
|
presentation: z.ZodEnum<["embedded", "hosted"]>;
|
|
24
24
|
attemptStartedAt: z.ZodNumber;
|
|
25
|
+
/** This tab issued the operation and has not yet seen it succeed. */
|
|
26
|
+
awaited: z.ZodOptional<z.ZodLiteral<true>>;
|
|
27
|
+
/** The end this tab saw it reach, kept only by a store that retains settled pointers. */
|
|
28
|
+
settled: z.ZodOptional<z.ZodEnum<["succeeded", "reconciliation_needed"]>>;
|
|
25
29
|
}, "strip", z.ZodTypeAny, {
|
|
26
30
|
kind: "cancel" | "subscription" | "topup";
|
|
27
31
|
attemptStartedAt: number;
|
|
28
32
|
presentation: "embedded" | "hosted";
|
|
29
33
|
operationId: string;
|
|
34
|
+
awaited?: true | undefined;
|
|
35
|
+
settled?: "succeeded" | "reconciliation_needed" | undefined;
|
|
30
36
|
}, {
|
|
31
37
|
kind: "cancel" | "subscription" | "topup";
|
|
32
38
|
attemptStartedAt: number;
|
|
33
39
|
presentation: "embedded" | "hosted";
|
|
34
40
|
operationId: string;
|
|
41
|
+
awaited?: true | undefined;
|
|
42
|
+
settled?: "succeeded" | "reconciliation_needed" | undefined;
|
|
35
43
|
}>;
|
|
36
44
|
export type BillingOperationPointer = z.infer<typeof PointerSchema>;
|
|
37
45
|
export declare function operationPointerKey(scope: BillingScope): string;
|
|
@@ -40,9 +48,26 @@ export interface OperationPointerStore {
|
|
|
40
48
|
write: (scope: BillingScope, pointer: BillingOperationPointer) => void;
|
|
41
49
|
/** Clears only when the stored pointer names `operationId`, or unconditionally without one. */
|
|
42
50
|
clear: (scope: BillingScope, operationId?: string) => void;
|
|
43
|
-
|
|
51
|
+
/**
|
|
52
|
+
* What the operation's phase does to the pointer naming it. A client-side
|
|
53
|
+
* timeout is this tab giving up, not the server finishing: an operation
|
|
54
|
+
* awaiting bank authentication stays pending for hours, so the pointer
|
|
55
|
+
* outlives it. A superseded operation belongs to a scope this tab left, and
|
|
56
|
+
* its pointer waits under that scope's key for a return. A failure clears
|
|
57
|
+
* it, so a reload offers a fresh attempt. A success, or an operation the
|
|
58
|
+
* server parked for reconciliation, clears it too, unless the store retains
|
|
59
|
+
* settled pointers.
|
|
60
|
+
*/
|
|
61
|
+
settle: (state: BillingOperationState) => void;
|
|
44
62
|
}
|
|
45
|
-
export
|
|
63
|
+
export interface OperationPointerStoreOptions {
|
|
64
|
+
/**
|
|
65
|
+
* Keep a settled pointer, marked with how it ended, so a page that is the
|
|
66
|
+
* checkout itself can read it back after a reload.
|
|
67
|
+
*/
|
|
68
|
+
readonly retainSettled?: boolean;
|
|
69
|
+
}
|
|
70
|
+
export declare function createOperationPointerStore(storage: BillingOperationPointerStorage, now?: () => number, { retainSettled }?: OperationPointerStoreOptions): OperationPointerStore;
|
|
46
71
|
/** A store for hosts without tab-local storage: nothing is ever recovered. */
|
|
47
72
|
export declare const NO_POINTER_STORE: OperationPointerStore;
|
|
48
73
|
export {};
|
|
@@ -15,24 +15,12 @@ const PointerSchema = z.object({
|
|
|
15
15
|
operationId: z.string().min(1),
|
|
16
16
|
kind: z.enum(['subscription', 'topup', 'cancel']),
|
|
17
17
|
presentation: z.enum(['embedded', 'hosted']),
|
|
18
|
-
attemptStartedAt: z.number().finite()
|
|
18
|
+
attemptStartedAt: z.number().finite(),
|
|
19
|
+
/** This tab issued the operation and has not yet seen it succeed. */
|
|
20
|
+
awaited: z.literal(true).optional(),
|
|
21
|
+
/** The end this tab saw it reach, kept only by a store that retains settled pointers. */
|
|
22
|
+
settled: z.enum(['succeeded', 'reconciliation_needed']).optional()
|
|
19
23
|
});
|
|
20
|
-
/**
|
|
21
|
-
* A client-side timeout is this tab giving up, not the server finishing:
|
|
22
|
-
* an operation awaiting bank authentication stays pending for hours, so the
|
|
23
|
-
* pointer outlives it. A superseded operation belongs to a scope this tab
|
|
24
|
-
* left, and its pointer waits under that scope's key for a return.
|
|
25
|
-
* `reconciliation_needed` is terminal for polling, so retaining its pointer
|
|
26
|
-
* would re-poll a settled operation on every reload.
|
|
27
|
-
*/
|
|
28
|
-
const CLEARS_POINTER = {
|
|
29
|
-
pending: false,
|
|
30
|
-
timed_out: false,
|
|
31
|
-
superseded: false,
|
|
32
|
-
succeeded: true,
|
|
33
|
-
failed: true,
|
|
34
|
-
reconciliation_needed: true
|
|
35
|
-
};
|
|
36
24
|
export function operationPointerKey(scope) {
|
|
37
25
|
return `${POINTER_KEY_PREFIX}:${scope.userId}:${scope.workspaceId}`;
|
|
38
26
|
}
|
|
@@ -56,7 +44,14 @@ function decodePointer(raw, now) {
|
|
|
56
44
|
? parsed.data
|
|
57
45
|
: undefined;
|
|
58
46
|
}
|
|
59
|
-
|
|
47
|
+
/** A success is no longer awaited; an operation still being reconciled is. */
|
|
48
|
+
function settledPointer(pointer, phase) {
|
|
49
|
+
if (phase === 'reconciliation_needed')
|
|
50
|
+
return { ...pointer, settled: phase };
|
|
51
|
+
const { awaited: _seen, ...rest } = pointer;
|
|
52
|
+
return { ...rest, settled: phase };
|
|
53
|
+
}
|
|
54
|
+
export function createOperationPointerStore(storage, now = Date.now, { retainSettled = false } = {}) {
|
|
60
55
|
const read = (scope) => {
|
|
61
56
|
const key = operationPointerKey(scope);
|
|
62
57
|
const pointer = decodePointer(attempt(() => storage.getItem(key), null), now());
|
|
@@ -64,26 +59,42 @@ export function createOperationPointerStore(storage, now = Date.now) {
|
|
|
64
59
|
attempt(() => storage.removeItem(key), undefined);
|
|
65
60
|
return pointer;
|
|
66
61
|
};
|
|
62
|
+
const write = (scope, pointer) => attempt(() => storage.setItem(operationPointerKey(scope), JSON.stringify(pointer)), undefined);
|
|
67
63
|
const clear = (scope, operationId) => {
|
|
68
64
|
if (operationId !== undefined && read(scope)?.operationId !== operationId) {
|
|
69
65
|
return;
|
|
70
66
|
}
|
|
71
67
|
attempt(() => storage.removeItem(operationPointerKey(scope)), undefined);
|
|
72
68
|
};
|
|
73
|
-
|
|
74
|
-
read
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
69
|
+
const retain = (state, phase) => {
|
|
70
|
+
const pointer = read(state.scope);
|
|
71
|
+
if (pointer?.operationId !== state.id)
|
|
72
|
+
return;
|
|
73
|
+
write(state.scope, settledPointer(pointer, phase));
|
|
74
|
+
};
|
|
75
|
+
const settle = (state) => {
|
|
76
|
+
switch (state.phase) {
|
|
77
|
+
case 'pending':
|
|
78
|
+
case 'timed_out':
|
|
79
|
+
case 'superseded':
|
|
80
|
+
return;
|
|
81
|
+
case 'failed':
|
|
79
82
|
clear(state.scope, state.id);
|
|
83
|
+
return;
|
|
84
|
+
case 'succeeded':
|
|
85
|
+
case 'reconciliation_needed':
|
|
86
|
+
if (retainSettled)
|
|
87
|
+
retain(state, state.phase);
|
|
88
|
+
else
|
|
89
|
+
clear(state.scope, state.id);
|
|
80
90
|
}
|
|
81
91
|
};
|
|
92
|
+
return { read, write, clear, settle };
|
|
82
93
|
}
|
|
83
94
|
/** A store for hosts without tab-local storage: nothing is ever recovered. */
|
|
84
95
|
export const NO_POINTER_STORE = {
|
|
85
96
|
read: () => undefined,
|
|
86
97
|
write: () => { },
|
|
87
98
|
clear: () => { },
|
|
88
|
-
|
|
99
|
+
settle: () => { }
|
|
89
100
|
};
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type { PendingBillingOperation } from './operationState.js';
|
|
1
|
+
import type { BillingAuthenticationState, EmbeddedChallenge, PendingBillingOperation } from './operationState.js';
|
|
2
2
|
export declare const OPERATION_POLL_TIMING: {
|
|
3
3
|
readonly initialMs: 1000;
|
|
4
4
|
readonly maxMs: 8000;
|
|
5
5
|
readonly multiplier: 1.5;
|
|
6
6
|
/** An operation parked on the customer is checked on a slow, flat cadence. */
|
|
7
7
|
readonly parkedMs: 30000;
|
|
8
|
+
/** Fast backoff for an actionless blocked wait: 20 turns of the 3 s PI status cache. */
|
|
9
|
+
readonly actionDiscoveryMs: 60000;
|
|
8
10
|
};
|
|
9
11
|
export declare const OPERATION_POLL_BUDGET: {
|
|
10
12
|
readonly defaultMs: 120000;
|
|
@@ -14,13 +16,48 @@ export declare const OPERATION_POLL_BUDGET: {
|
|
|
14
16
|
readonly customerActionMs: number;
|
|
15
17
|
};
|
|
16
18
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* challenge
|
|
21
|
-
|
|
19
|
+
* What one tab holds for the customer on a pending operation. Each rail
|
|
20
|
+
* builds it from its own state, because each renders a different surface:
|
|
21
|
+
* the legacy cloud store offers the hosted link beside the embedded
|
|
22
|
+
* challenge, while the SDK renders exactly one presentation.
|
|
23
|
+
*/
|
|
24
|
+
export interface CustomerActionHold {
|
|
25
|
+
readonly authenticationState?: BillingAuthenticationState | null;
|
|
26
|
+
/** A hosted page this tab would open for the customer. */
|
|
27
|
+
readonly offersHostedPage: boolean;
|
|
28
|
+
/** The embedded challenge this tab holds, whatever became of it. */
|
|
29
|
+
readonly challenge?: EmbeddedChallenge['status'];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The one "customer can act here" rule both billing rails park on: a
|
|
33
|
+
* retryable failure to retry, a hosted page to visit, or an embedded
|
|
34
|
+
* challenge the server still says is required. A non-retryable decline never
|
|
35
|
+
* reaches it; the operation is terminal by then.
|
|
36
|
+
*/
|
|
37
|
+
export declare function customerCanActHere(hold: CustomerActionHold): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* The SDK renders one presentation: a hosted operation offers only its page,
|
|
40
|
+
* an embedded one only its challenge. The cloud app's
|
|
41
|
+
* `legacyOperationActionHold` names the rows where the two surfaces differ.
|
|
42
|
+
*/
|
|
43
|
+
export declare function pendingOperationActionHold(state: PendingBillingOperation): CustomerActionHold;
|
|
44
|
+
/**
|
|
45
|
+
* Parked only while the customer can act here. The server can report a
|
|
46
|
+
* blocked phase and a client secret before its cached `authentication_state`
|
|
47
|
+
* catches up, and the slow cadence would then hold a screen with no action.
|
|
22
48
|
*/
|
|
23
49
|
export declare function isParkedOnCustomer(state: PendingBillingOperation): boolean;
|
|
24
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Blocked on the customer with nothing to offer them yet: either the action
|
|
52
|
+
* is still on its way, or it belongs to someone else (a member without
|
|
53
|
+
* billing permission, a tab without embedded checkout).
|
|
54
|
+
*/
|
|
55
|
+
export declare function isWaitingOnCustomerWithoutAction(state: PendingBillingOperation): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* `waitedWithoutActionMs` is how long the operation has continuously been
|
|
58
|
+
* {@link isWaitingOnCustomerWithoutAction}; past the discovery window it
|
|
59
|
+
* parks too, so an action that never arrives here costs the slow cadence.
|
|
60
|
+
*/
|
|
61
|
+
export declare function nextPollDelayMs(state: PendingBillingOperation, previousDelayMs: number | undefined, waitedWithoutActionMs?: number): number;
|
|
25
62
|
export declare function pollBudgetMs(state: PendingBillingOperation): number;
|
|
26
63
|
export declare function hasExhaustedPollBudget(state: PendingBillingOperation, now: number): boolean;
|