@comfyorg/account-core 1.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/dist/core/billing/balanceWatch.d.ts +35 -0
  3. package/dist/core/billing/balanceWatch.js +83 -0
  4. package/dist/core/billing/billingContracts.d.ts +123 -0
  5. package/dist/core/billing/billingContracts.js +11 -0
  6. package/dist/core/billing/billingErrorBody.d.ts +8 -0
  7. package/dist/core/billing/billingErrorBody.js +11 -0
  8. package/dist/core/billing/billingScope.d.ts +39 -0
  9. package/dist/core/billing/billingScope.js +45 -0
  10. package/dist/core/billing/capabilities.d.ts +70 -0
  11. package/dist/core/billing/capabilities.js +137 -0
  12. package/dist/core/billing/capabilityDenials.d.ts +26 -0
  13. package/dist/core/billing/capabilityDenials.js +43 -0
  14. package/dist/core/billing/challengeDriver.d.ts +22 -0
  15. package/dist/core/billing/challengeDriver.js +38 -0
  16. package/dist/core/billing/credentialedTransport.d.ts +35 -0
  17. package/dist/core/billing/credentialedTransport.js +53 -0
  18. package/dist/core/billing/credits.d.ts +43 -0
  19. package/dist/core/billing/credits.js +29 -0
  20. package/dist/core/billing/httpStatus.d.ts +7 -0
  21. package/dist/core/billing/httpStatus.js +25 -0
  22. package/dist/core/billing/index.d.ts +52 -0
  23. package/dist/core/billing/index.js +23 -0
  24. package/dist/core/billing/operationLifecycle.d.ts +77 -0
  25. package/dist/core/billing/operationLifecycle.js +547 -0
  26. package/dist/core/billing/operationPointer.d.ts +48 -0
  27. package/dist/core/billing/operationPointer.js +89 -0
  28. package/dist/core/billing/operationPolicy.d.ts +26 -0
  29. package/dist/core/billing/operationPolicy.js +51 -0
  30. package/dist/core/billing/operationState.d.ts +128 -0
  31. package/dist/core/billing/operationState.js +187 -0
  32. package/dist/core/billing/paymentCopy.d.ts +18 -0
  33. package/dist/core/billing/paymentCopy.js +47 -0
  34. package/dist/core/billing/paymentMethods.d.ts +40 -0
  35. package/dist/core/billing/paymentMethods.js +37 -0
  36. package/dist/core/billing/paymentProjection.d.ts +34 -0
  37. package/dist/core/billing/paymentProjection.js +56 -0
  38. package/dist/core/billing/plans.d.ts +44 -0
  39. package/dist/core/billing/plans.js +29 -0
  40. package/dist/core/billing/presentation.d.ts +20 -0
  41. package/dist/core/billing/presentation.js +10 -0
  42. package/dist/core/billing/scopedReader.d.ts +61 -0
  43. package/dist/core/billing/scopedReader.js +108 -0
  44. package/dist/core/billing/sharedRead.d.ts +33 -0
  45. package/dist/core/billing/sharedRead.js +59 -0
  46. package/dist/core/billing/status.d.ts +27 -0
  47. package/dist/core/billing/status.js +21 -0
  48. package/dist/core/billing/subscriptionCommands.d.ts +367 -0
  49. package/dist/core/billing/subscriptionCommands.js +263 -0
  50. package/dist/core/billing/topup.d.ts +113 -0
  51. package/dist/core/billing/topup.js +209 -0
  52. package/dist/core/billing/transport.d.ts +14 -0
  53. package/dist/core/billing/transport.js +106 -0
  54. package/dist/core/billing/transportExchange.d.ts +24 -0
  55. package/dist/core/billing/transportExchange.js +79 -0
  56. package/dist/core/boundedOperation.d.ts +16 -0
  57. package/dist/core/boundedOperation.js +12 -0
  58. package/dist/core/credentialCache.d.ts +41 -0
  59. package/dist/core/credentialCache.js +97 -0
  60. package/dist/core/customerRecovery.d.ts +39 -0
  61. package/dist/core/customerRecovery.js +79 -0
  62. package/dist/core/exchange.d.ts +50 -0
  63. package/dist/core/exchange.js +137 -0
  64. package/dist/core/identity.d.ts +21 -0
  65. package/dist/core/identity.js +16 -0
  66. package/dist/core/mintCoordinator.d.ts +18 -0
  67. package/dist/core/mintCoordinator.js +31 -0
  68. package/dist/core/refreshScheduler.d.ts +46 -0
  69. package/dist/core/refreshScheduler.js +262 -0
  70. package/dist/core/session.d.ts +146 -0
  71. package/dist/core/session.js +312 -0
  72. package/dist/core/sessionContracts.d.ts +135 -0
  73. package/dist/core/sessionContracts.js +9 -0
  74. package/dist/core/sessionState.d.ts +118 -0
  75. package/dist/core/sessionState.js +235 -0
  76. package/dist/firebase/index.d.ts +53 -0
  77. package/dist/firebase/index.js +74 -0
  78. package/dist/firebaseAuthError.d.ts +52 -0
  79. package/dist/firebaseAuthError.js +88 -0
  80. package/dist/loadExternalScript.d.ts +7 -0
  81. package/dist/loadExternalScript.js +88 -0
  82. package/dist/provisioning.d.ts +53 -0
  83. package/dist/provisioning.js +76 -0
  84. package/dist/redirect.d.ts +13 -0
  85. package/dist/redirect.js +38 -0
  86. package/dist/signInSchemas.d.ts +88 -0
  87. package/dist/signInSchemas.js +71 -0
  88. package/dist/telemetry.d.ts +39 -0
  89. package/dist/telemetry.js +26 -0
  90. package/dist/testing.d.ts +7 -0
  91. package/dist/testing.js +3 -0
  92. package/dist/turnstile.d.ts +14 -0
  93. package/dist/turnstile.js +17 -0
  94. package/dist/turnstileScript.d.ts +18 -0
  95. package/dist/turnstileScript.js +6 -0
  96. package/dist/web/crossTabRefresh.d.ts +18 -0
  97. package/dist/web/crossTabRefresh.js +121 -0
  98. package/dist/webviewDetection.d.ts +5 -0
  99. package/dist/webviewDetection.js +62 -0
  100. package/package.json +114 -0
@@ -0,0 +1,547 @@
1
+ /**
2
+ * The operation lifecycle every billing command sits on: adopt a
3
+ * `billing_op_id`, observe it to a terminal state, and reattach to it after a
4
+ * reload, a focus, or a return to the workspace — without ever creating a
5
+ * replacement operation. Commands issue the request; this owns everything
6
+ * that happens to the id afterwards.
7
+ *
8
+ * Framework-free by construction. Nothing here opens a URL, drives a
9
+ * payment-provider challenge, or listens to a document: a host renders the
10
+ * state (`actionUrl`, `challenge`), performs those effects itself, and
11
+ * reports back through `reportChallenge*` and `wake`.
12
+ *
13
+ * Behavior is ported from the cloud app's `billingOperationStore` — the
14
+ * cadence, the budgets, the parked-on-customer rules, the challenge echo
15
+ * suppression — and from the pending-checkout pointer's terminal rule; the
16
+ * wiring onto the scope source, the scope tracker, and the generated
17
+ * contract is new.
18
+ */
19
+ import { zBillingOpStatusResponse } from '@comfyorg/ingest-types/zod';
20
+ import { BILLING_OPERATION_TELEMETRY_EVENT } from '../../telemetry.js';
21
+ import { createBillingScopeTracker } from './billingScope.js';
22
+ import { NO_POINTER_STORE, createOperationPointerStore } from './operationPointer.js';
23
+ import { hasExhaustedPollBudget, nextPollDelayMs } from './operationPolicy.js';
24
+ import { isTerminal, reduceBillingOperation, validateActionUrl } from './operationState.js';
25
+ import { selectBillingPresentation } from './presentation.js';
26
+ import { readValidatedBillingResponse } from './sharedRead.js';
27
+ export function operationRoute(operationId) {
28
+ return `/billing/ops/${encodeURIComponent(operationId)}`;
29
+ }
30
+ const NOT_AUTHENTICATED = {
31
+ status: 'error',
32
+ code: 'NOT_AUTHENTICATED'
33
+ };
34
+ const SUPERSEDED = {
35
+ status: 'error',
36
+ code: 'SUPERSEDED'
37
+ };
38
+ function continuationOf(issued) {
39
+ return {
40
+ ...(issued.actionUrl === undefined ? {} : { actionUrl: issued.actionUrl }),
41
+ ...(issued.clientSecret === undefined
42
+ ? {}
43
+ : { clientSecret: issued.clientSecret })
44
+ };
45
+ }
46
+ function pendingFromStatus(status) {
47
+ if (status.pending_billing_op_id === undefined)
48
+ return undefined;
49
+ return {
50
+ id: status.pending_billing_op_id,
51
+ kind: status.pending_billing_op_type === 'topup' ? 'topup' : 'subscription',
52
+ ...continuationOf({
53
+ actionUrl: status.action_url,
54
+ clientSecret: status.payment_intent_client_secret
55
+ })
56
+ };
57
+ }
58
+ function initialPendingState(input, observedAt, presentation) {
59
+ const actionUrl = validateActionUrl(input.actionUrl);
60
+ const challenge = input.clientSecret === undefined || input.presentation !== 'embedded'
61
+ ? undefined
62
+ : { clientSecret: input.clientSecret, status: 'required' };
63
+ return {
64
+ id: input.id,
65
+ kind: input.kind,
66
+ scope: input.context.scope,
67
+ ...presentation,
68
+ observedAt,
69
+ attemptStartedAt: input.attemptStartedAt,
70
+ phase: 'pending',
71
+ ...(actionUrl === undefined ? {} : { actionUrl }),
72
+ ...(challenge === undefined ? {} : { challenge }),
73
+ customerActionSeen: actionUrl !== undefined || input.clientSecret !== undefined
74
+ };
75
+ }
76
+ function failureCategoryFor(state) {
77
+ switch (state.phase) {
78
+ case 'succeeded':
79
+ return undefined;
80
+ case 'failed':
81
+ return state.kind === 'cancel' ? 'api_rejected' : 'provider_decline';
82
+ case 'timed_out':
83
+ return 'poll_timeout';
84
+ case 'reconciliation_needed':
85
+ return 'reconciliation_needed';
86
+ case 'superseded':
87
+ return 'stale_operation';
88
+ }
89
+ }
90
+ /** The pause is this tab's own challenge on screen; a hosted page never pauses. */
91
+ function isDrivingChallenge(state) {
92
+ return (state.presentation === 'embedded' &&
93
+ state.challenge?.status === 'in_progress');
94
+ }
95
+ export function createBillingOperationLifecycle(options) {
96
+ const { transport, scopeSource, statusReader, embeddedCheckoutAvailable = () => false, hostedDestination = () => 'stripe', onTelemetry, now = Date.now } = options;
97
+ const pointers = options.pointerStorage === undefined
98
+ ? NO_POINTER_STORE
99
+ : createOperationPointerStore(options.pointerStorage, now);
100
+ const operations = new Map();
101
+ const inFlightCommands = new Map();
102
+ const listeners = new Set();
103
+ const lifetime = { disposed: false };
104
+ // Role is part of the scope, so a demotion mid-payment supersedes the
105
+ // operation and this tab stops observing it. The pointer key omits role on
106
+ // purpose: the charge still belongs to (user, workspace), so `recover` finds
107
+ // it again under the new role. Keying the pointer by role would strand it.
108
+ const scopeTracker = createBillingScopeTracker(scopeSource, () => {
109
+ for (const record of operations.values()) {
110
+ dispatch(record, { type: 'superseded' });
111
+ }
112
+ });
113
+ function isLive(context) {
114
+ return !lifetime.disposed && scopeTracker.isCurrent(context);
115
+ }
116
+ function publish(record) {
117
+ for (const listener of [...listeners])
118
+ listener(record.state);
119
+ }
120
+ function stopTimer(record) {
121
+ if (record.timer === undefined)
122
+ return;
123
+ clearTimeout(record.timer);
124
+ record.timer = undefined;
125
+ }
126
+ function emitTerminalTelemetry(record) {
127
+ const state = record.state;
128
+ if (!isTerminal(state))
129
+ return;
130
+ const category = failureCategoryFor(state);
131
+ onTelemetry?.({
132
+ name: state.phase === 'succeeded'
133
+ ? BILLING_OPERATION_TELEMETRY_EVENT.succeeded
134
+ : state.phase === 'timed_out'
135
+ ? BILLING_OPERATION_TELEMETRY_EVENT.timeout
136
+ : BILLING_OPERATION_TELEMETRY_EVENT.failed,
137
+ billing_op_id: state.id,
138
+ operation_type: state.kind,
139
+ presentation: state.presentation,
140
+ resumed: record.resumed,
141
+ ...(category === undefined ? {} : { failure_category: category }),
142
+ ...(state.phase === 'failed'
143
+ ? { decline_reason: state.declineReason }
144
+ : {}),
145
+ duration_ms: now() - state.attemptStartedAt
146
+ });
147
+ }
148
+ function dispatch(record, event) {
149
+ const next = reduceBillingOperation(record.state, event);
150
+ if (next === record.state)
151
+ return;
152
+ record.state = next;
153
+ if (isTerminal(next)) {
154
+ stopTimer(record);
155
+ pointers.clearIfTerminal(next);
156
+ emitTerminalTelemetry(record);
157
+ record.resolveSettled(next);
158
+ }
159
+ publish(record);
160
+ }
161
+ function schedule(record) {
162
+ if (record.state.phase !== 'pending')
163
+ return;
164
+ stopTimer(record);
165
+ const delayMs = nextPollDelayMs(record.state, record.delayMs);
166
+ record.delayMs = delayMs;
167
+ record.timer = setTimeout(() => void poll(record), delayMs);
168
+ }
169
+ // One request per record at a time: a wake arriving mid-poll joins the
170
+ // poll in flight instead of doubling the request rate. The request is
171
+ // bound to the record, not the id, so an id re-adopted after supersession
172
+ // starts its own observation instead of joining a poll that can no longer
173
+ // schedule it.
174
+ function poll(record) {
175
+ if (record.inFlightPoll !== undefined)
176
+ return record.inFlightPoll;
177
+ const request = pollOnce(record).finally(() => {
178
+ record.inFlightPoll = undefined;
179
+ });
180
+ record.inFlightPoll = request;
181
+ return request;
182
+ }
183
+ function writePointer(scope, state) {
184
+ pointers.write(scope, {
185
+ operationId: state.id,
186
+ kind: state.kind,
187
+ presentation: state.presentation,
188
+ attemptStartedAt: state.attemptStartedAt
189
+ });
190
+ }
191
+ function readOperation(operationId) {
192
+ return readValidatedBillingResponse(transport, { method: 'GET', route: operationRoute(operationId) }, (body) => zBillingOpStatusResponse.safeParse(body));
193
+ }
194
+ function continueOrExpire(record) {
195
+ if (record.state.phase !== 'pending')
196
+ return;
197
+ if (hasExhaustedPollBudget(record.state, now())) {
198
+ dispatch(record, { type: 'timed_out' });
199
+ return;
200
+ }
201
+ schedule(record);
202
+ }
203
+ async function pollOnce(record) {
204
+ const state = record.state;
205
+ if (state.phase !== 'pending')
206
+ return;
207
+ stopTimer(record);
208
+ if (isDrivingChallenge(state))
209
+ return;
210
+ if (!scopeTracker.isCurrent(record.context)) {
211
+ dispatch(record, { type: 'superseded' });
212
+ return;
213
+ }
214
+ if (hasExhaustedPollBudget(state, now())) {
215
+ dispatch(record, { type: 'timed_out' });
216
+ return;
217
+ }
218
+ const response = await readOperation(state.id);
219
+ if (record.state.phase !== 'pending')
220
+ return;
221
+ if (!isLive(record.context)) {
222
+ dispatch(record, { type: 'superseded' });
223
+ return;
224
+ }
225
+ applyPollResponse(record, response);
226
+ }
227
+ function applyPollResponse(record, response) {
228
+ if (response.status === 'ok') {
229
+ dispatch(record, { type: 'status_polled', status: response.value.data });
230
+ continueOrExpire(record);
231
+ return;
232
+ }
233
+ if (response.code === 'SUPERSEDED' ||
234
+ response.code === 'ACCESS_DENIED' ||
235
+ response.code === 'NOT_AUTHENTICATED') {
236
+ dispatch(record, { type: 'superseded' });
237
+ }
238
+ else if (response.code === 'NOT_FOUND') {
239
+ dispatch(record, { type: 'lost' });
240
+ }
241
+ else {
242
+ continueOrExpire(record);
243
+ }
244
+ }
245
+ /** Read at every adoption, so an operation recovered from the pointer lands on today's destination. */
246
+ function destinationFor(presentation) {
247
+ return presentation === 'hosted'
248
+ ? { presentation, hostedDestination: hostedDestination() }
249
+ : { presentation };
250
+ }
251
+ function adopt(input) {
252
+ const existing = operations.get(input.id);
253
+ if (existing !== undefined &&
254
+ existing.state.phase !== 'timed_out' &&
255
+ existing.state.phase !== 'superseded') {
256
+ return existing;
257
+ }
258
+ const state = initialPendingState(input, now(), destinationFor(input.presentation));
259
+ let resolveSettled = () => { };
260
+ const settled = new Promise((resolve) => {
261
+ resolveSettled = resolve;
262
+ });
263
+ const record = {
264
+ state,
265
+ context: input.context,
266
+ resumed: input.resumed,
267
+ delayMs: undefined,
268
+ timer: undefined,
269
+ inFlightPoll: undefined,
270
+ settled,
271
+ resolveSettled
272
+ };
273
+ operations.set(input.id, record);
274
+ writePointer(input.context.scope, state);
275
+ onTelemetry?.({
276
+ name: BILLING_OPERATION_TELEMETRY_EVENT.started,
277
+ billing_op_id: input.id,
278
+ operation_type: input.kind,
279
+ presentation: input.presentation,
280
+ resumed: input.resumed
281
+ });
282
+ publish(record);
283
+ if (input.initialStatus === undefined) {
284
+ void poll(record);
285
+ }
286
+ else {
287
+ dispatch(record, { type: 'status_polled', status: input.initialStatus });
288
+ continueOrExpire(record);
289
+ }
290
+ return record;
291
+ }
292
+ function routeFor(rail, issued) {
293
+ return selectBillingPresentation({
294
+ embeddedCheckoutAvailable: embeddedCheckoutAvailable(),
295
+ billingRail: rail,
296
+ ...(issued.actionUrl === undefined
297
+ ? {}
298
+ : { hostedUrl: issued.actionUrl }),
299
+ ...(issued.clientSecret === undefined
300
+ ? {}
301
+ : { clientSecret: issued.clientSecret })
302
+ });
303
+ }
304
+ async function beginOnce(kind, context, issue) {
305
+ // The backend's word on what is already pending comes first: a second
306
+ // charge attempt is never issued over one the server is still settling.
307
+ const status = await statusReader.read();
308
+ // A scope change outranks a failed read, as in recover().
309
+ if (!isLive(context))
310
+ return SUPERSEDED;
311
+ if (status.status === 'error')
312
+ return status;
313
+ const rail = status.value.status.billing_rail;
314
+ const pending = pendingFromStatus(status.value.status);
315
+ if (pending !== undefined && pending.kind === kind) {
316
+ const record = adopt({
317
+ ...pending,
318
+ context,
319
+ presentation: routeFor(rail, pending),
320
+ attemptStartedAt: now(),
321
+ resumed: true
322
+ });
323
+ return { status: 'ok', value: record.state };
324
+ }
325
+ const attemptStartedAt = now();
326
+ const issued = await issue(context.scope);
327
+ if (!isLive(context)) {
328
+ // The operation exists server-side under the scope this tab just left;
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;
339
+ }
340
+ if (issued.status === 'error')
341
+ return issued;
342
+ const record = adopt({
343
+ id: issued.value.operationId,
344
+ kind,
345
+ context,
346
+ presentation: routeFor(rail, issued.value),
347
+ ...continuationOf(issued.value),
348
+ attemptStartedAt,
349
+ resumed: false
350
+ });
351
+ return { status: 'ok', value: record.state };
352
+ }
353
+ function begin(kind, issue) {
354
+ if (lifetime.disposed)
355
+ return Promise.resolve(SUPERSEDED);
356
+ const context = scopeTracker.capture();
357
+ if (context === undefined)
358
+ return Promise.resolve(NOT_AUTHENTICATED);
359
+ // Single-flight holds only within a scope: an attempt the scope change
360
+ // already doomed to SUPERSEDED must not stand in for this caller's.
361
+ const inFlight = inFlightCommands.get(kind);
362
+ if (inFlight !== undefined && isLive(inFlight.context)) {
363
+ return inFlight.promise;
364
+ }
365
+ const attempt = beginOnce(kind, context, issue).finally(() => {
366
+ if (inFlightCommands.get(kind)?.promise === attempt) {
367
+ inFlightCommands.delete(kind);
368
+ }
369
+ });
370
+ inFlightCommands.set(kind, { context, promise: attempt });
371
+ return attempt;
372
+ }
373
+ // Unreachable is not "nothing pending": the pointer is the only evidence
374
+ // left, and observing it costs a poll while reissuing could cost a charge.
375
+ function recoverFromPointer(failure, pointer, context) {
376
+ if (failure.code !== 'REQUEST_FAILED' || pointer === undefined) {
377
+ return failure;
378
+ }
379
+ const record = adopt({
380
+ id: pointer.operationId,
381
+ kind: pointer.kind,
382
+ context,
383
+ presentation: pointer.presentation,
384
+ attemptStartedAt: pointer.attemptStartedAt,
385
+ resumed: true
386
+ });
387
+ return { status: 'ok', value: record.state };
388
+ }
389
+ function resumeServerPending(pending, rail, pointer, context) {
390
+ const known = pointer?.operationId === pending.id ? pointer : undefined;
391
+ const record = adopt({
392
+ ...pending,
393
+ context,
394
+ presentation: known?.presentation ?? routeFor(rail, pending),
395
+ attemptStartedAt: known?.attemptStartedAt ?? now(),
396
+ resumed: true
397
+ });
398
+ return { status: 'ok', value: record.state };
399
+ }
400
+ // The server reports nothing pending, so the pointed-at operation has
401
+ // either settled since this tab last saw it or never belonged to this
402
+ // scope. One read decides which; a stale pointer is dropped, never
403
+ // re-observed on a schedule.
404
+ async function probePointer(pointer, context) {
405
+ const probe = await readOperation(pointer.operationId);
406
+ if (!isLive(context))
407
+ return SUPERSEDED;
408
+ if (probe.status === 'error') {
409
+ if (probe.code !== 'NOT_FOUND')
410
+ return probe;
411
+ pointers.clear(context.scope, pointer.operationId);
412
+ return { status: 'ok', value: undefined };
413
+ }
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
+ });
423
+ return { status: 'ok', value: record.state };
424
+ }
425
+ async function recover() {
426
+ if (lifetime.disposed)
427
+ return SUPERSEDED;
428
+ const context = scopeTracker.capture();
429
+ if (context === undefined)
430
+ return NOT_AUTHENTICATED;
431
+ const status = await statusReader.read();
432
+ if (!isLive(context))
433
+ return SUPERSEDED;
434
+ const pointer = pointers.read(context.scope);
435
+ if (status.status === 'error') {
436
+ return recoverFromPointer(status, pointer, context);
437
+ }
438
+ const pending = pendingFromStatus(status.value.status);
439
+ if (pending !== undefined) {
440
+ return resumeServerPending(pending, status.value.status.billing_rail, pointer, context);
441
+ }
442
+ if (pointer === undefined)
443
+ return { status: 'ok', value: undefined };
444
+ return probePointer(pointer, context);
445
+ }
446
+ function wake() {
447
+ for (const record of operations.values()) {
448
+ if (record.state.phase !== 'pending')
449
+ continue;
450
+ if (isDrivingChallenge(record.state))
451
+ continue;
452
+ void poll(record);
453
+ }
454
+ }
455
+ function refusedSwitch(state, presentation) {
456
+ if (state.presentation === presentation)
457
+ return 'unchanged';
458
+ if (presentation === 'hosted') {
459
+ return state.actionUrl === undefined ? 'no_hosted_url' : undefined;
460
+ }
461
+ return !embeddedCheckoutAvailable() || state.challenge === undefined
462
+ ? 'embedded_unavailable'
463
+ : undefined;
464
+ }
465
+ function switchEvent(presentation) {
466
+ return presentation === 'hosted'
467
+ ? {
468
+ type: 'presentation_switched',
469
+ presentation,
470
+ hostedDestination: hostedDestination()
471
+ }
472
+ : { type: 'presentation_switched', presentation };
473
+ }
474
+ function switchPresentation(operationId, presentation) {
475
+ const record = operations.get(operationId);
476
+ if (record === undefined)
477
+ return 'unknown_operation';
478
+ const state = record.state;
479
+ if (state.phase !== 'pending')
480
+ return 'not_pending';
481
+ const refusal = refusedSwitch(state, presentation);
482
+ if (refusal !== undefined)
483
+ return refusal;
484
+ dispatch(record, switchEvent(presentation));
485
+ writePointer(record.context.scope, record.state);
486
+ record.delayMs = undefined;
487
+ schedule(record);
488
+ return 'switched';
489
+ }
490
+ function reportChallengeStarted(operationId) {
491
+ const record = operations.get(operationId);
492
+ if (record === undefined)
493
+ return;
494
+ dispatch(record, { type: 'challenge_started' });
495
+ if (record.state.phase === 'pending' && isDrivingChallenge(record.state)) {
496
+ stopTimer(record);
497
+ }
498
+ }
499
+ function reportChallengeSettled(operationId, outcome) {
500
+ const record = operations.get(operationId);
501
+ if (record === undefined || record.state.phase !== 'pending')
502
+ return;
503
+ dispatch(record, { type: 'challenge_settled', outcome });
504
+ record.delayMs = undefined;
505
+ // A completed challenge is read back at once; a failed one is not a
506
+ // verdict on the payment — the server may have seen it succeed — so the
507
+ // operation stays observed on the parked cadence rather than stopping.
508
+ if (outcome === 'completed')
509
+ void poll(record);
510
+ else
511
+ schedule(record);
512
+ }
513
+ return {
514
+ begin,
515
+ recover,
516
+ wake,
517
+ switchPresentation,
518
+ reportChallengeStarted,
519
+ reportChallengeSettled,
520
+ get: (operationId) => operations.get(operationId)?.state,
521
+ getSnapshot: () => [...operations.values()].map((record) => record.state),
522
+ subscribe: (listener) => {
523
+ listeners.add(listener);
524
+ return () => listeners.delete(listener);
525
+ },
526
+ settled: (operationId) => operations.get(operationId)?.settled,
527
+ dispose: () => {
528
+ lifetime.disposed = true;
529
+ // Every pending settlement resolves so no awaiting host hangs; this is
530
+ // the tab giving up observation, not an outcome, so no telemetry.
531
+ for (const record of operations.values()) {
532
+ stopTimer(record);
533
+ const next = reduceBillingOperation(record.state, {
534
+ type: 'superseded'
535
+ });
536
+ if (next === record.state)
537
+ continue;
538
+ record.state = next;
539
+ record.resolveSettled(next);
540
+ }
541
+ operations.clear();
542
+ inFlightCommands.clear();
543
+ listeners.clear();
544
+ scopeTracker.dispose();
545
+ }
546
+ };
547
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The tab-local pointer to an operation this tab is observing, so a reload
3
+ * reattaches to the same `billing_op_id` instead of starting a replacement
4
+ * charge. The host supplies the storage (origin-scoped `sessionStorage` in a
5
+ * browser); the SDK owns the key, the shape, and the rule for when the
6
+ * pointer may go.
7
+ *
8
+ * The pointer is keyed by scope, so a workspace switch neither reads nor
9
+ * clears another workspace's operation, and switching back finds it again.
10
+ */
11
+ import { z } from 'zod';
12
+ import type { BillingScope } from './billingScope.js';
13
+ import type { BillingOperationState } from './operationState.js';
14
+ export interface BillingOperationPointerStorage {
15
+ getItem: (key: string) => string | null;
16
+ setItem: (key: string, value: string) => void;
17
+ removeItem: (key: string) => void;
18
+ }
19
+ export declare const OPERATION_POINTER_MAX_AGE_MS: number;
20
+ declare const PointerSchema: z.ZodObject<{
21
+ operationId: z.ZodString;
22
+ kind: z.ZodEnum<["subscription", "topup", "cancel"]>;
23
+ presentation: z.ZodEnum<["embedded", "hosted"]>;
24
+ attemptStartedAt: z.ZodNumber;
25
+ }, "strip", z.ZodTypeAny, {
26
+ kind: "cancel" | "subscription" | "topup";
27
+ attemptStartedAt: number;
28
+ presentation: "embedded" | "hosted";
29
+ operationId: string;
30
+ }, {
31
+ kind: "cancel" | "subscription" | "topup";
32
+ attemptStartedAt: number;
33
+ presentation: "embedded" | "hosted";
34
+ operationId: string;
35
+ }>;
36
+ export type BillingOperationPointer = z.infer<typeof PointerSchema>;
37
+ export declare function operationPointerKey(scope: BillingScope): string;
38
+ export interface OperationPointerStore {
39
+ read: (scope: BillingScope) => BillingOperationPointer | undefined;
40
+ write: (scope: BillingScope, pointer: BillingOperationPointer) => void;
41
+ /** Clears only when the stored pointer names `operationId`, or unconditionally without one. */
42
+ clear: (scope: BillingScope, operationId?: string) => void;
43
+ clearIfTerminal: (state: BillingOperationState) => void;
44
+ }
45
+ export declare function createOperationPointerStore(storage: BillingOperationPointerStorage, now?: () => number): OperationPointerStore;
46
+ /** A store for hosts without tab-local storage: nothing is ever recovered. */
47
+ export declare const NO_POINTER_STORE: OperationPointerStore;
48
+ export {};
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The tab-local pointer to an operation this tab is observing, so a reload
3
+ * reattaches to the same `billing_op_id` instead of starting a replacement
4
+ * charge. The host supplies the storage (origin-scoped `sessionStorage` in a
5
+ * browser); the SDK owns the key, the shape, and the rule for when the
6
+ * pointer may go.
7
+ *
8
+ * The pointer is keyed by scope, so a workspace switch neither reads nor
9
+ * clears another workspace's operation, and switching back finds it again.
10
+ */
11
+ import { z } from 'zod';
12
+ const POINTER_KEY_PREFIX = 'comfy:billing:operation';
13
+ export const OPERATION_POINTER_MAX_AGE_MS = 24 * 60 * 60_000;
14
+ const PointerSchema = z.object({
15
+ operationId: z.string().min(1),
16
+ kind: z.enum(['subscription', 'topup', 'cancel']),
17
+ presentation: z.enum(['embedded', 'hosted']),
18
+ attemptStartedAt: z.number().finite()
19
+ });
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
+ export function operationPointerKey(scope) {
37
+ return `${POINTER_KEY_PREFIX}:${scope.userId}:${scope.workspaceId}`;
38
+ }
39
+ /** Storage that throws (private mode, quota) reads as empty and writes as a no-op. */
40
+ function attempt(read, fallback) {
41
+ try {
42
+ return read();
43
+ }
44
+ catch {
45
+ return fallback;
46
+ }
47
+ }
48
+ function decodePointer(raw, now) {
49
+ if (raw === null)
50
+ return undefined;
51
+ const parsed = PointerSchema.safeParse(attempt(() => JSON.parse(raw), null));
52
+ if (!parsed.success)
53
+ return undefined;
54
+ const age = now - parsed.data.attemptStartedAt;
55
+ return age >= 0 && age <= OPERATION_POINTER_MAX_AGE_MS
56
+ ? parsed.data
57
+ : undefined;
58
+ }
59
+ export function createOperationPointerStore(storage, now = Date.now) {
60
+ const read = (scope) => {
61
+ const key = operationPointerKey(scope);
62
+ const pointer = decodePointer(attempt(() => storage.getItem(key), null), now());
63
+ if (pointer === undefined)
64
+ attempt(() => storage.removeItem(key), undefined);
65
+ return pointer;
66
+ };
67
+ const clear = (scope, operationId) => {
68
+ if (operationId !== undefined && read(scope)?.operationId !== operationId) {
69
+ return;
70
+ }
71
+ attempt(() => storage.removeItem(operationPointerKey(scope)), undefined);
72
+ };
73
+ return {
74
+ read,
75
+ write: (scope, pointer) => attempt(() => storage.setItem(operationPointerKey(scope), JSON.stringify(pointer)), undefined),
76
+ clear,
77
+ clearIfTerminal: (state) => {
78
+ if (CLEARS_POINTER[state.phase])
79
+ clear(state.scope, state.id);
80
+ }
81
+ };
82
+ }
83
+ /** A store for hosts without tab-local storage: nothing is ever recovered. */
84
+ export const NO_POINTER_STORE = {
85
+ read: () => undefined,
86
+ write: () => { },
87
+ clear: () => { },
88
+ clearIfTerminal: () => { }
89
+ };