@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.
- package/LICENSE +21 -0
- package/dist/core/billing/balanceWatch.d.ts +35 -0
- package/dist/core/billing/balanceWatch.js +83 -0
- package/dist/core/billing/billingContracts.d.ts +123 -0
- package/dist/core/billing/billingContracts.js +11 -0
- package/dist/core/billing/billingErrorBody.d.ts +8 -0
- package/dist/core/billing/billingErrorBody.js +11 -0
- package/dist/core/billing/billingScope.d.ts +39 -0
- package/dist/core/billing/billingScope.js +45 -0
- package/dist/core/billing/capabilities.d.ts +70 -0
- package/dist/core/billing/capabilities.js +137 -0
- package/dist/core/billing/capabilityDenials.d.ts +26 -0
- package/dist/core/billing/capabilityDenials.js +43 -0
- package/dist/core/billing/challengeDriver.d.ts +22 -0
- package/dist/core/billing/challengeDriver.js +38 -0
- package/dist/core/billing/credentialedTransport.d.ts +35 -0
- package/dist/core/billing/credentialedTransport.js +53 -0
- package/dist/core/billing/credits.d.ts +43 -0
- package/dist/core/billing/credits.js +29 -0
- package/dist/core/billing/httpStatus.d.ts +7 -0
- package/dist/core/billing/httpStatus.js +25 -0
- package/dist/core/billing/index.d.ts +52 -0
- package/dist/core/billing/index.js +23 -0
- package/dist/core/billing/operationLifecycle.d.ts +77 -0
- package/dist/core/billing/operationLifecycle.js +547 -0
- package/dist/core/billing/operationPointer.d.ts +48 -0
- package/dist/core/billing/operationPointer.js +89 -0
- package/dist/core/billing/operationPolicy.d.ts +26 -0
- package/dist/core/billing/operationPolicy.js +51 -0
- package/dist/core/billing/operationState.d.ts +128 -0
- package/dist/core/billing/operationState.js +187 -0
- package/dist/core/billing/paymentCopy.d.ts +18 -0
- package/dist/core/billing/paymentCopy.js +47 -0
- package/dist/core/billing/paymentMethods.d.ts +40 -0
- package/dist/core/billing/paymentMethods.js +37 -0
- package/dist/core/billing/paymentProjection.d.ts +34 -0
- package/dist/core/billing/paymentProjection.js +56 -0
- package/dist/core/billing/plans.d.ts +44 -0
- package/dist/core/billing/plans.js +29 -0
- package/dist/core/billing/presentation.d.ts +20 -0
- package/dist/core/billing/presentation.js +10 -0
- package/dist/core/billing/scopedReader.d.ts +61 -0
- package/dist/core/billing/scopedReader.js +108 -0
- package/dist/core/billing/sharedRead.d.ts +33 -0
- package/dist/core/billing/sharedRead.js +59 -0
- package/dist/core/billing/status.d.ts +27 -0
- package/dist/core/billing/status.js +21 -0
- package/dist/core/billing/subscriptionCommands.d.ts +367 -0
- package/dist/core/billing/subscriptionCommands.js +263 -0
- package/dist/core/billing/topup.d.ts +113 -0
- package/dist/core/billing/topup.js +209 -0
- package/dist/core/billing/transport.d.ts +14 -0
- package/dist/core/billing/transport.js +106 -0
- package/dist/core/billing/transportExchange.d.ts +24 -0
- package/dist/core/billing/transportExchange.js +79 -0
- package/dist/core/boundedOperation.d.ts +16 -0
- package/dist/core/boundedOperation.js +12 -0
- package/dist/core/credentialCache.d.ts +41 -0
- package/dist/core/credentialCache.js +97 -0
- package/dist/core/customerRecovery.d.ts +39 -0
- package/dist/core/customerRecovery.js +79 -0
- package/dist/core/exchange.d.ts +50 -0
- package/dist/core/exchange.js +137 -0
- package/dist/core/identity.d.ts +21 -0
- package/dist/core/identity.js +16 -0
- package/dist/core/mintCoordinator.d.ts +18 -0
- package/dist/core/mintCoordinator.js +31 -0
- package/dist/core/refreshScheduler.d.ts +46 -0
- package/dist/core/refreshScheduler.js +262 -0
- package/dist/core/session.d.ts +146 -0
- package/dist/core/session.js +312 -0
- package/dist/core/sessionContracts.d.ts +135 -0
- package/dist/core/sessionContracts.js +9 -0
- package/dist/core/sessionState.d.ts +118 -0
- package/dist/core/sessionState.js +235 -0
- package/dist/firebase/index.d.ts +53 -0
- package/dist/firebase/index.js +74 -0
- package/dist/firebaseAuthError.d.ts +52 -0
- package/dist/firebaseAuthError.js +88 -0
- package/dist/loadExternalScript.d.ts +7 -0
- package/dist/loadExternalScript.js +88 -0
- package/dist/provisioning.d.ts +53 -0
- package/dist/provisioning.js +76 -0
- package/dist/redirect.d.ts +13 -0
- package/dist/redirect.js +38 -0
- package/dist/signInSchemas.d.ts +88 -0
- package/dist/signInSchemas.js +71 -0
- package/dist/telemetry.d.ts +39 -0
- package/dist/telemetry.js +26 -0
- package/dist/testing.d.ts +7 -0
- package/dist/testing.js +3 -0
- package/dist/turnstile.d.ts +14 -0
- package/dist/turnstile.js +17 -0
- package/dist/turnstileScript.d.ts +18 -0
- package/dist/turnstileScript.js +6 -0
- package/dist/web/crossTabRefresh.d.ts +18 -0
- package/dist/web/crossTabRefresh.js +121 -0
- package/dist/webviewDetection.d.ts +5 -0
- package/dist/webviewDetection.js +62 -0
- 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
|
+
};
|