@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.
Files changed (62) hide show
  1. package/dist/core/billing/billingContracts.d.ts +29 -14
  2. package/dist/core/billing/billingErrorBody.d.ts +8 -4
  3. package/dist/core/billing/billingErrorBody.js +14 -4
  4. package/dist/core/billing/credentialedTransport.d.ts +23 -7
  5. package/dist/core/billing/credentialedTransport.js +72 -8
  6. package/dist/core/billing/events.d.ts +47 -0
  7. package/dist/core/billing/events.js +43 -0
  8. package/dist/core/billing/index.d.ts +16 -11
  9. package/dist/core/billing/index.js +8 -6
  10. package/dist/core/billing/operationLifecycle.d.ts +34 -1
  11. package/dist/core/billing/operationLifecycle.js +146 -67
  12. package/dist/core/billing/operationPointer.d.ts +27 -2
  13. package/dist/core/billing/operationPointer.js +36 -25
  14. package/dist/core/billing/operationPolicy.d.ts +44 -7
  15. package/dist/core/billing/operationPolicy.js +64 -3
  16. package/dist/core/billing/operationState.d.ts +102 -18
  17. package/dist/core/billing/operationState.js +78 -8
  18. package/dist/core/billing/paymentCopy.d.ts +6 -1
  19. package/dist/core/billing/paymentCopy.js +34 -1
  20. package/dist/core/billing/paymentProjection.d.ts +14 -2
  21. package/dist/core/billing/paymentProjection.js +37 -9
  22. package/dist/core/billing/scopedReader.d.ts +11 -5
  23. package/dist/core/billing/scopedReader.js +20 -9
  24. package/dist/core/billing/sharedRead.js +4 -2
  25. package/dist/core/billing/subscriptionCommands.d.ts +37 -9
  26. package/dist/core/billing/subscriptionCommands.js +42 -30
  27. package/dist/core/billing/topup.d.ts +26 -0
  28. package/dist/core/billing/topup.js +36 -2
  29. package/dist/core/billing/wireCents.d.ts +8 -0
  30. package/dist/core/billing/wireCents.js +8 -0
  31. package/dist/core/billing/workspaceInvites.d.ts +12 -0
  32. package/dist/core/billing/workspaceInvites.js +26 -0
  33. package/dist/core/identity.d.ts +6 -6
  34. package/dist/core/identity.js +4 -10
  35. package/dist/core/lazyIdentity.d.ts +31 -0
  36. package/dist/core/lazyIdentity.js +96 -0
  37. package/dist/core/requestAuth.d.ts +30 -0
  38. package/dist/core/requestAuth.js +41 -0
  39. package/dist/core/session.d.ts +12 -18
  40. package/dist/core/session.js +16 -26
  41. package/dist/core/sessionContracts.d.ts +39 -2
  42. package/dist/core/sessionTokenMint.d.ts +33 -0
  43. package/dist/core/sessionTokenMint.js +205 -0
  44. package/dist/core/webSession.d.ts +25 -0
  45. package/dist/core/webSession.js +137 -0
  46. package/dist/core/webSessionFlag.d.ts +20 -0
  47. package/dist/core/webSessionFlag.js +62 -0
  48. package/dist/core/webSessionIdentity.d.ts +291 -0
  49. package/dist/core/webSessionIdentity.js +496 -0
  50. package/dist/firebase/configSource.d.ts +54 -0
  51. package/dist/firebase/configSource.js +111 -0
  52. package/dist/firebase/index.d.ts +71 -8
  53. package/dist/firebase/index.js +236 -23
  54. package/dist/firebase/popupWatch.d.ts +42 -0
  55. package/dist/firebase/popupWatch.js +140 -0
  56. package/dist/testing.d.ts +38 -0
  57. package/dist/testing.js +116 -0
  58. package/dist/web/crossTabRefresh.d.ts +4 -2
  59. package/dist/web/crossTabRefresh.js +13 -0
  60. package/dist/workspaceLink.d.ts +45 -0
  61. package/dist/workspaceLink.js +78 -0
  62. package/package.json +30 -6
@@ -5,6 +5,7 @@ function publishAsRead(value) {
5
5
  }
6
6
  export function createScopedReader(definition) {
7
7
  const { transport, scopeSource, route, parse, project, timeoutMs, cached, publish = publishAsRead } = definition;
8
+ const resolveRoute = typeof route === 'string' ? () => route : route;
8
9
  let snapshot;
9
10
  let inFlight;
10
11
  const lifetime = { disposed: false };
@@ -12,11 +13,11 @@ export function createScopedReader(definition) {
12
13
  snapshot = undefined;
13
14
  inFlight = undefined;
14
15
  });
15
- async function request(scope, options) {
16
+ async function request(scope, options, resolvedRoute) {
16
17
  const budget = timeoutMs?.(options);
17
18
  const billingRequest = {
18
19
  method: 'GET',
19
- route,
20
+ route: resolvedRoute,
20
21
  ...(budget === undefined ? {} : { timeoutMs: budget })
21
22
  };
22
23
  const response = await readValidatedBillingResponse(transport, billingRequest, parse);
@@ -30,8 +31,8 @@ export function createScopedReader(definition) {
30
31
  snapshot = published;
31
32
  return { status: 'ok', value: published ?? value };
32
33
  }
33
- async function settle(context, pending, options) {
34
- const result = await request(context.scope, options);
34
+ async function settle(context, pending, options, resolvedRoute) {
35
+ const result = await request(context.scope, options, resolvedRoute);
35
36
  // The publish guard. Between issuing the request and settling it the host
36
37
  // may have changed workspace or signed out, so an answer cached now would
37
38
  // be attributed to whoever is signed in next, and a failure says nothing
@@ -60,10 +61,19 @@ export function createScopedReader(definition) {
60
61
  const served = cached?.(snapshot, context.scope, options);
61
62
  if (served !== undefined)
62
63
  return { status: 'ok', value: served };
63
- // One read per scope. A caller arriving mid-flight for the same scope
64
- // joins rather than issuing a second identical request; a caller for a
65
- // different scope starts its own, and the older one can no longer publish.
66
- if (matchesScopedRead(inFlight, context)) {
64
+ // Resolved once, then used both to issue the request and to identify it,
65
+ // so the answer a joining caller is served is the one its own route asked
66
+ // for.
67
+ const resolvedRoute = resolveRoute(options);
68
+ // A caller arriving mid-flight joins only a read of the same scope and
69
+ // route, rather than issuing a second identical request. A caller for a
70
+ // different scope starts its own, and the older one can no longer
71
+ // publish. A caller for a different route on the same scope also starts
72
+ // its own and takes the slot, so the displaced read still settles and
73
+ // publishes — the snapshot carries which page it is — but nobody can join
74
+ // it any more. One slot is enough for a feed read one page at a time.
75
+ if (matchesScopedRead(inFlight, context) &&
76
+ inFlight.route === resolvedRoute) {
67
77
  return releaseOnAbort(inFlight.promise, options?.signal);
68
78
  }
69
79
  // No caller signal reaches the shared request: it is bounded by its own
@@ -72,7 +82,8 @@ export function createScopedReader(definition) {
72
82
  const pending = { fenced: false };
73
83
  const attempt = {
74
84
  context,
75
- promise: settle(context, pending, options),
85
+ route: resolvedRoute,
86
+ promise: settle(context, pending, options, resolvedRoute),
76
87
  pending
77
88
  };
78
89
  inFlight = attempt;
@@ -1,5 +1,5 @@
1
1
  import { sameBillingScope } from './billingScope.js';
2
- import { readBillingErrorCode } from './billingErrorBody.js';
2
+ import { readBillingErrorCode, readBillingErrorMessage } from './billingErrorBody.js';
3
3
  import { codeForHttpStatus } from './httpStatus.js';
4
4
  /**
5
5
  * An abandoned wait is transient, the same bucket the transport reports an
@@ -22,11 +22,13 @@ export async function readValidatedBillingResponse(transport, request, parse) {
22
22
  const { httpStatus, body } = response.value;
23
23
  if (httpStatus < 200 || httpStatus >= 300) {
24
24
  const serverCode = readBillingErrorCode(body);
25
+ const serverMessage = readBillingErrorMessage(body);
25
26
  return {
26
27
  status: 'error',
27
28
  code: codeForHttpStatus(response.value),
28
29
  httpStatus,
29
- ...(serverCode === undefined ? {} : { serverCode })
30
+ ...(serverCode === undefined ? {} : { serverCode }),
31
+ ...(serverMessage === undefined ? {} : { serverMessage })
30
32
  };
31
33
  }
32
34
  const parsed = parse(body);
@@ -35,7 +35,9 @@ export type SubscriptionCommandCode =
35
35
  /** The server wants the host to re-preview and resend with `confirm_reactivation`. */
36
36
  | 'REACTIVATION_CONFIRMATION_REQUIRED' | 'NO_ACTIVE_SUBSCRIPTION'
37
37
  /** The server asked for a hosted payment step but offered no page for it. */
38
- | 'MISSING_PAYMENT_METHOD_URL';
38
+ | 'MISSING_PAYMENT_METHOD_URL'
39
+ /** The quote no longer matches what the server would charge; re-preview. */
40
+ | 'QUOTE_STALE';
39
41
  export type SubscriptionCommandFailure = BillingFailure | {
40
42
  readonly status: 'error';
41
43
  readonly code: SubscriptionCommandCode;
@@ -89,6 +91,8 @@ declare const PreviewSchema: z.ZodObject<{
89
91
  credits_next_period_cents: z.ZodNumber;
90
92
  credits_today_cents: z.ZodNumber;
91
93
  renewal_amount_cents: z.ZodOptional<z.ZodNumber>;
94
+ subtotal_cents: z.ZodOptional<z.ZodNumber>;
95
+ balance_applied_cents: z.ZodOptional<z.ZodNumber>;
92
96
  current_plan: z.ZodOptional<z.ZodObject<{
93
97
  duration: z.ZodEnum<["MONTHLY", "ANNUAL"]>;
94
98
  period_end: z.ZodOptional<z.ZodString>;
@@ -98,6 +102,7 @@ declare const PreviewSchema: z.ZodObject<{
98
102
  } & {
99
103
  credits_cents: z.ZodNumber;
100
104
  price_cents: z.ZodNumber;
105
+ list_price_cents: z.ZodOptional<z.ZodNumber>;
101
106
  seat_summary: z.ZodObject<{
102
107
  seat_count: z.ZodNumber;
103
108
  } & {
@@ -114,8 +119,8 @@ declare const PreviewSchema: z.ZodObject<{
114
119
  }>;
115
120
  }, "strip", z.ZodTypeAny, {
116
121
  price_cents: number;
117
- credits_cents: number;
118
122
  duration: "MONTHLY" | "ANNUAL";
123
+ credits_cents: number;
119
124
  seat_summary: {
120
125
  seat_count: number;
121
126
  total_cost_cents: number;
@@ -125,10 +130,11 @@ declare const PreviewSchema: z.ZodObject<{
125
130
  tier: "FREE" | "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION" | "TEAM" | "ENTERPRISE";
126
131
  period_end?: string | undefined;
127
132
  period_start?: string | undefined;
133
+ list_price_cents?: number | undefined;
128
134
  }, {
129
135
  price_cents: number;
130
- credits_cents: number;
131
136
  duration: "MONTHLY" | "ANNUAL";
137
+ credits_cents: number;
132
138
  seat_summary: {
133
139
  seat_count: number;
134
140
  total_cost_cents: number;
@@ -138,6 +144,7 @@ declare const PreviewSchema: z.ZodObject<{
138
144
  tier: "FREE" | "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION" | "TEAM" | "ENTERPRISE";
139
145
  period_end?: string | undefined;
140
146
  period_start?: string | undefined;
147
+ list_price_cents?: number | undefined;
141
148
  }>>;
142
149
  new_plan: z.ZodObject<{
143
150
  duration: z.ZodEnum<["MONTHLY", "ANNUAL"]>;
@@ -148,6 +155,7 @@ declare const PreviewSchema: z.ZodObject<{
148
155
  } & {
149
156
  credits_cents: z.ZodNumber;
150
157
  price_cents: z.ZodNumber;
158
+ list_price_cents: z.ZodOptional<z.ZodNumber>;
151
159
  seat_summary: z.ZodObject<{
152
160
  seat_count: z.ZodNumber;
153
161
  } & {
@@ -164,8 +172,8 @@ declare const PreviewSchema: z.ZodObject<{
164
172
  }>;
165
173
  }, "strip", z.ZodTypeAny, {
166
174
  price_cents: number;
167
- credits_cents: number;
168
175
  duration: "MONTHLY" | "ANNUAL";
176
+ credits_cents: number;
169
177
  seat_summary: {
170
178
  seat_count: number;
171
179
  total_cost_cents: number;
@@ -175,10 +183,11 @@ declare const PreviewSchema: z.ZodObject<{
175
183
  tier: "FREE" | "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION" | "TEAM" | "ENTERPRISE";
176
184
  period_end?: string | undefined;
177
185
  period_start?: string | undefined;
186
+ list_price_cents?: number | undefined;
178
187
  }, {
179
188
  price_cents: number;
180
- credits_cents: number;
181
189
  duration: "MONTHLY" | "ANNUAL";
190
+ credits_cents: number;
182
191
  seat_summary: {
183
192
  seat_count: number;
184
193
  total_cost_cents: number;
@@ -188,23 +197,30 @@ declare const PreviewSchema: z.ZodObject<{
188
197
  tier: "FREE" | "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION" | "TEAM" | "ENTERPRISE";
189
198
  period_end?: string | undefined;
190
199
  period_start?: string | undefined;
200
+ list_price_cents?: number | undefined;
191
201
  }>;
192
202
  discounts: z.ZodOptional<z.ZodArray<z.ZodObject<{
193
203
  code: z.ZodString;
204
+ duration: z.ZodOptional<z.ZodEnum<["once", "repeating", "forever"]>>;
194
205
  kind: z.ZodEnum<["plan", "promotion"]>;
195
206
  name: z.ZodOptional<z.ZodString>;
196
207
  } & {
197
208
  amount_off_cents: z.ZodOptional<z.ZodNumber>;
209
+ duration_in_months: z.ZodOptional<z.ZodNumber>;
198
210
  }, "strip", z.ZodTypeAny, {
199
211
  code: string;
200
212
  kind: "plan" | "promotion";
201
213
  name?: string | undefined;
202
214
  amount_off_cents?: number | undefined;
215
+ duration?: "once" | "repeating" | "forever" | undefined;
216
+ duration_in_months?: number | undefined;
203
217
  }, {
204
218
  code: string;
205
219
  kind: "plan" | "promotion";
206
220
  name?: string | undefined;
207
221
  amount_off_cents?: number | undefined;
222
+ duration?: "once" | "repeating" | "forever" | undefined;
223
+ duration_in_months?: number | undefined;
208
224
  }>, "many">>;
209
225
  }, "strip", z.ZodTypeAny, {
210
226
  effective_at: string;
@@ -216,8 +232,8 @@ declare const PreviewSchema: z.ZodObject<{
216
232
  is_immediate: boolean;
217
233
  new_plan: {
218
234
  price_cents: number;
219
- credits_cents: number;
220
235
  duration: "MONTHLY" | "ANNUAL";
236
+ credits_cents: number;
221
237
  seat_summary: {
222
238
  seat_count: number;
223
239
  total_cost_cents: number;
@@ -227,6 +243,7 @@ declare const PreviewSchema: z.ZodObject<{
227
243
  tier: "FREE" | "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION" | "TEAM" | "ENTERPRISE";
228
244
  period_end?: string | undefined;
229
245
  period_start?: string | undefined;
246
+ list_price_cents?: number | undefined;
230
247
  };
231
248
  transition_type: "new_subscription" | "upgrade" | "downgrade" | "duration_change";
232
249
  currency?: string | undefined;
@@ -235,10 +252,11 @@ declare const PreviewSchema: z.ZodObject<{
235
252
  quote_id?: string | undefined;
236
253
  quote_version?: number | undefined;
237
254
  amount_due_cents?: number | undefined;
255
+ balance_applied_cents?: number | undefined;
238
256
  current_plan?: {
239
257
  price_cents: number;
240
- credits_cents: number;
241
258
  duration: "MONTHLY" | "ANNUAL";
259
+ credits_cents: number;
242
260
  seat_summary: {
243
261
  seat_count: number;
244
262
  total_cost_cents: number;
@@ -248,18 +266,22 @@ declare const PreviewSchema: z.ZodObject<{
248
266
  tier: "FREE" | "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION" | "TEAM" | "ENTERPRISE";
249
267
  period_end?: string | undefined;
250
268
  period_start?: string | undefined;
269
+ list_price_cents?: number | undefined;
251
270
  } | undefined;
252
271
  discounts?: {
253
272
  code: string;
254
273
  kind: "plan" | "promotion";
255
274
  name?: string | undefined;
256
275
  amount_off_cents?: number | undefined;
276
+ duration?: "once" | "repeating" | "forever" | undefined;
277
+ duration_in_months?: number | undefined;
257
278
  }[] | undefined;
258
279
  payment_method_configuration_id?: string | undefined;
259
280
  reason?: string | undefined;
260
281
  renewal_amount_cents?: number | undefined;
261
282
  renewal_at?: string | undefined;
262
283
  requires_reactivation_confirmation?: boolean | undefined;
284
+ subtotal_cents?: number | undefined;
263
285
  }, {
264
286
  effective_at: string;
265
287
  allowed: boolean;
@@ -270,8 +292,8 @@ declare const PreviewSchema: z.ZodObject<{
270
292
  is_immediate: boolean;
271
293
  new_plan: {
272
294
  price_cents: number;
273
- credits_cents: number;
274
295
  duration: "MONTHLY" | "ANNUAL";
296
+ credits_cents: number;
275
297
  seat_summary: {
276
298
  seat_count: number;
277
299
  total_cost_cents: number;
@@ -281,6 +303,7 @@ declare const PreviewSchema: z.ZodObject<{
281
303
  tier: "FREE" | "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION" | "TEAM" | "ENTERPRISE";
282
304
  period_end?: string | undefined;
283
305
  period_start?: string | undefined;
306
+ list_price_cents?: number | undefined;
284
307
  };
285
308
  transition_type: "new_subscription" | "upgrade" | "downgrade" | "duration_change";
286
309
  currency?: string | undefined;
@@ -289,10 +312,11 @@ declare const PreviewSchema: z.ZodObject<{
289
312
  quote_id?: string | undefined;
290
313
  quote_version?: number | undefined;
291
314
  amount_due_cents?: number | undefined;
315
+ balance_applied_cents?: number | undefined;
292
316
  current_plan?: {
293
317
  price_cents: number;
294
- credits_cents: number;
295
318
  duration: "MONTHLY" | "ANNUAL";
319
+ credits_cents: number;
296
320
  seat_summary: {
297
321
  seat_count: number;
298
322
  total_cost_cents: number;
@@ -302,18 +326,22 @@ declare const PreviewSchema: z.ZodObject<{
302
326
  tier: "FREE" | "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION" | "TEAM" | "ENTERPRISE";
303
327
  period_end?: string | undefined;
304
328
  period_start?: string | undefined;
329
+ list_price_cents?: number | undefined;
305
330
  } | undefined;
306
331
  discounts?: {
307
332
  code: string;
308
333
  kind: "plan" | "promotion";
309
334
  name?: string | undefined;
310
335
  amount_off_cents?: number | undefined;
336
+ duration?: "once" | "repeating" | "forever" | undefined;
337
+ duration_in_months?: number | undefined;
311
338
  }[] | undefined;
312
339
  payment_method_configuration_id?: string | undefined;
313
340
  reason?: string | undefined;
314
341
  renewal_amount_cents?: number | undefined;
315
342
  renewal_at?: string | undefined;
316
343
  requires_reactivation_confirmation?: boolean | undefined;
344
+ subtotal_cents?: number | undefined;
317
345
  }>;
318
346
  /**
319
347
  * The quote the server returns for a plan change, in the generated field names.
@@ -16,6 +16,7 @@ import { z } from 'zod';
16
16
  import { matchesServerCode } from './billingContracts.js';
17
17
  import { validateActionUrl } from './operationState.js';
18
18
  import { readValidatedBillingResponse } from './sharedRead.js';
19
+ import { wireCents } from './wireCents.js';
19
20
  export const SUBSCRIBE_ROUTE = '/billing/subscribe';
20
21
  export const RESUBSCRIBE_ROUTE = '/billing/subscription/resubscribe';
21
22
  export const CANCEL_SUBSCRIPTION_ROUTE = '/billing/subscription/cancel';
@@ -26,35 +27,33 @@ const NO_ACTIVE_SUBSCRIPTION_SERVER_CODE = 'NO_ACTIVE_SUBSCRIPTION';
26
27
  const REACTIVATION_CONFIRMATION_REQUIRED_SERVER_CODE = 'REACTIVATION_CONFIRMATION_REQUIRED';
27
28
  const NOT_SCHEDULED_FOR_CANCELLATION_SERVER_CODE = 'NOT_SCHEDULED_FOR_CANCELLATION';
28
29
  const ALREADY_CANCELED_SERVER_CODE = 'ALREADY_CANCELED';
29
- /**
30
- * The generated schema coerces every int64 to a `bigint`, which no caller can
31
- * add to a price or hand to a currency formatter — and the generated *type*
32
- * for the same field is a `number`. Money on this route is bounded to cents
33
- * well inside the JavaScript-safe range, so the cents are read as numbers, the
34
- * way `capabilities` reads `revision` — as whole units of currency that
35
- * survive arithmetic, since these amounts are displayed as prices and
36
- * confirmed as charges.
37
- */
38
- const cents = z.number().int().safe();
30
+ const SUBSCRIPTION_QUOTE_STALE_SERVER_CODE = 'SUBSCRIPTION_QUOTE_STALE';
31
+ const SUBSCRIPTION_CHANGE_IN_PROGRESS_SERVER_CODE = 'SUBSCRIPTION_CHANGE_IN_PROGRESS';
39
32
  const PlanInfoSchema = zPreviewPlanInfo.extend({
40
- credits_cents: cents,
41
- price_cents: cents,
33
+ credits_cents: wireCents,
34
+ price_cents: wireCents,
35
+ list_price_cents: wireCents.optional(),
42
36
  seat_summary: zPreviewPlanInfo.shape.seat_summary.extend({
43
- total_cost_cents: cents,
44
- total_credits_cents: cents
37
+ total_cost_cents: wireCents,
38
+ total_credits_cents: wireCents
45
39
  })
46
40
  });
47
41
  const PreviewSchema = zPreviewSubscribeResponse.extend({
48
- amount_due_cents: cents.optional(),
49
- cost_next_period_cents: cents,
50
- cost_today_cents: cents,
51
- credits_next_period_cents: cents,
52
- credits_today_cents: cents,
53
- renewal_amount_cents: cents.optional(),
42
+ amount_due_cents: wireCents.optional(),
43
+ cost_next_period_cents: wireCents,
44
+ cost_today_cents: wireCents,
45
+ credits_next_period_cents: wireCents,
46
+ credits_today_cents: wireCents,
47
+ renewal_amount_cents: wireCents.optional(),
48
+ subtotal_cents: wireCents.optional(),
49
+ balance_applied_cents: wireCents.optional(),
54
50
  current_plan: PlanInfoSchema.optional(),
55
51
  new_plan: PlanInfoSchema,
56
52
  discounts: z
57
- .array(zSubscriptionDiscount.extend({ amount_off_cents: cents.optional() }))
53
+ .array(zSubscriptionDiscount.extend({
54
+ amount_off_cents: wireCents.optional(),
55
+ duration_in_months: wireCents.optional()
56
+ }))
58
57
  .optional()
59
58
  });
60
59
  const ALREADY_HELD = {
@@ -65,23 +64,33 @@ function coded(code) {
65
64
  return { status: 'error', code };
66
65
  }
67
66
  /**
68
- * A server code the caller's request already satisfies is a success, but
69
- * only from a 4xx: a 5xx echoing the code is an upstream failure that
70
- * happens to carry it, and the requested state cannot be assumed to hold.
67
+ * A server code is trusted as state only from a 4xx: a 5xx echoing the code
68
+ * is an upstream failure that happens to carry it, and the state it names
69
+ * cannot be assumed to hold.
71
70
  */
72
- function alreadyInRequestedState(failure, serverCode) {
71
+ function refusedWith(failure, serverCode) {
73
72
  return (matchesServerCode(failure, serverCode) &&
74
73
  failure.httpStatus !== undefined &&
75
74
  failure.httpStatus >= 400 &&
76
75
  failure.httpStatus < 500);
77
76
  }
77
+ /**
78
+ * The server's refusal because another subscription operation is still open
79
+ * is the same answer the lifecycle gives when it sees that operation first.
80
+ */
81
+ function refusedWhilePending(failure) {
82
+ return refusedWith(failure, SUBSCRIPTION_CHANGE_IN_PROGRESS_SERVER_CODE)
83
+ ? { ...failure, code: 'OPERATION_ALREADY_PENDING' }
84
+ : failure;
85
+ }
78
86
  function mapServerCode(failure, alreadyHeldCode) {
79
- if (alreadyInRequestedState(failure, alreadyHeldCode)) {
87
+ // A server code the caller's request already satisfies is a success.
88
+ if (refusedWith(failure, alreadyHeldCode)) {
80
89
  return { status: 'already_held' };
81
90
  }
82
91
  return matchesServerCode(failure, NO_ACTIVE_SUBSCRIPTION_SERVER_CODE)
83
92
  ? coded('NO_ACTIVE_SUBSCRIPTION')
84
- : failure;
93
+ : refusedWhilePending(failure);
85
94
  }
86
95
  function dropEmpty(value) {
87
96
  return value === '' ? undefined : value;
@@ -159,9 +168,12 @@ export function createBillingCommands(options) {
159
168
  const key = idempotencyKey();
160
169
  const response = await post(SUBSCRIBE_ROUTE, { ...input, idempotency_key: key }, (body) => zSubscribeResponse.safeParse(body), key);
161
170
  if (response.status === 'error') {
162
- return matchesServerCode(response, REACTIVATION_CONFIRMATION_REQUIRED_SERVER_CODE)
163
- ? coded('REACTIVATION_CONFIRMATION_REQUIRED')
164
- : response;
171
+ if (matchesServerCode(response, REACTIVATION_CONFIRMATION_REQUIRED_SERVER_CODE)) {
172
+ return coded('REACTIVATION_CONFIRMATION_REQUIRED');
173
+ }
174
+ return refusedWith(response, SUBSCRIPTION_QUOTE_STALE_SERVER_CODE)
175
+ ? coded('QUOTE_STALE')
176
+ : refusedWhilePending(response);
165
177
  }
166
178
  const { billing_op_id, status, payment_method_url } = response.value.data;
167
179
  if (status !== 'needs_payment_method') {
@@ -11,6 +11,26 @@ export declare const TOPUP_ROUTE = "/billing/topup";
11
11
  * through a webhook, so completion is the host's return plus a balance watch.
12
12
  */
13
13
  export declare const TOPUP_CHECKOUT_ROUTE = "/billing/topup/checkout";
14
+ /** A read the backend shapes as a POST: nothing is charged or reserved. */
15
+ export declare const TOPUP_QUOTE_ROUTE = "/billing/topup/quote";
16
+ /**
17
+ * What the server says an amount buys. `credits` is the server's display
18
+ * count; `expiresAt` is when the grant would lapse, which the server may
19
+ * move, so a host never states the expiry policy itself.
20
+ */
21
+ export interface TopupQuote {
22
+ readonly amountCents: number;
23
+ readonly credits: number;
24
+ readonly expiresAt: string;
25
+ }
26
+ export type TopupQuoteResult = {
27
+ readonly status: 'ok';
28
+ readonly value: TopupQuote;
29
+ } | BillingFailure | TopupNotAvailable | TopupInvalidAmount;
30
+ export interface QuoteTopupInput {
31
+ readonly amountCents: number;
32
+ readonly signal?: AbortSignal;
33
+ }
14
34
  type OperationIn<P extends BillingOperationState['phase']> = Extract<BillingOperationState, {
15
35
  readonly phase: P;
16
36
  }>;
@@ -101,6 +121,12 @@ export interface TopupCommandOptions {
101
121
  readonly idempotencyKey?: () => string;
102
122
  }
103
123
  export interface TopupCommand {
124
+ /**
125
+ * Quotes an amount without starting anything. It does not check the plan
126
+ * tier or the payment method, so a quote does not promise the top-up will
127
+ * go through.
128
+ */
129
+ quoteTopup: (input: QuoteTopupInput) => Promise<TopupQuoteResult>;
104
130
  createTopupCheckout: (input: CreateTopupCheckoutInput) => Promise<TopupResult>;
105
131
  /**
106
132
  * The route Workshop and Platform ship on today. Unlike
@@ -10,10 +10,11 @@
10
10
  * own vocabulary, and the terminal operation is embedded whenever one was
11
11
  * observed so a host reads the coded decline and recovery action from it.
12
12
  */
13
- import { zCreateTopupCheckoutRequest, zCreateTopupCheckoutResponse, zCreateTopupRequest, zCreateTopupResponse } from '@comfyorg/ingest-types/zod';
13
+ import { zCreateTopupCheckoutRequest, zCreateTopupCheckoutResponse, zCreateTopupRequest, zCreateTopupResponse, zTopupQuoteResponse } from '@comfyorg/ingest-types/zod';
14
14
  import { matchesServerCode } from './billingContracts.js';
15
15
  import { isTerminal, validateActionUrl } from './operationState.js';
16
16
  import { readValidatedBillingResponse } from './sharedRead.js';
17
+ import { wireCents } from './wireCents.js';
17
18
  export const TOPUP_ROUTE = '/billing/topup';
18
19
  /**
19
20
  * The hosted variant: a provider-hosted Checkout session with no operation to
@@ -21,6 +22,14 @@ export const TOPUP_ROUTE = '/billing/topup';
21
22
  * through a webhook, so completion is the host's return plus a balance watch.
22
23
  */
23
24
  export const TOPUP_CHECKOUT_ROUTE = '/billing/topup/checkout';
25
+ /** A read the backend shapes as a POST: nothing is charged or reserved. */
26
+ export const TOPUP_QUOTE_ROUTE = '/billing/topup/quote';
27
+ /** The server's own server code for an amount it will not sell. */
28
+ const INVALID_AMOUNT_SERVER_CODE = 'INVALID_AMOUNT';
29
+ const TopupQuoteSchema = zTopupQuoteResponse.extend({
30
+ amount_cents: wireCents,
31
+ credits: wireCents
32
+ });
24
33
  /** The one coded error body this command acts on; every other code stays a `BillingFailure`. */
25
34
  const NO_PAYMENT_METHOD_SERVER_CODE = 'NO_PAYMENT_METHOD';
26
35
  const INVALID_AMOUNT = {
@@ -205,5 +214,30 @@ export function createTopupCommand(options) {
205
214
  : {})
206
215
  };
207
216
  }
208
- return { createTopupCheckout, createHostedTopupCheckout };
217
+ async function quoteTopup(input) {
218
+ const { amountCents, signal } = input;
219
+ if (!wireCents.positive().safeParse(amountCents).success)
220
+ return INVALID_AMOUNT;
221
+ const response = await readValidatedBillingResponse(transport, {
222
+ method: 'POST',
223
+ route: TOPUP_QUOTE_ROUTE,
224
+ body: { amount_cents: amountCents },
225
+ ...(signal === undefined ? {} : { signal })
226
+ }, (raw) => TopupQuoteSchema.safeParse(raw));
227
+ if (response.status === 'error') {
228
+ if (matchesServerCode(response, INVALID_AMOUNT_SERVER_CODE))
229
+ return INVALID_AMOUNT;
230
+ return response.httpStatus === 404 ? NOT_AVAILABLE : response;
231
+ }
232
+ const quote = response.value.data;
233
+ return {
234
+ status: 'ok',
235
+ value: {
236
+ amountCents: quote.amount_cents,
237
+ credits: quote.credits,
238
+ expiresAt: quote.expires_at
239
+ }
240
+ };
241
+ }
242
+ return { quoteTopup, createTopupCheckout, createHostedTopupCheckout };
209
243
  }
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * The generated schemas coerce every int64 to a `bigint`, which no caller can
4
+ * add to a price or hand to a currency formatter, while the generated *type*
5
+ * for the same field is a `number`. Billing amounts and credit counts sit well
6
+ * inside the JavaScript-safe range, so they are read as safe integers.
7
+ */
8
+ export declare const wireCents: z.ZodNumber;
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * The generated schemas coerce every int64 to a `bigint`, which no caller can
4
+ * add to a price or hand to a currency formatter, while the generated *type*
5
+ * for the same field is a `number`. Billing amounts and credit counts sit well
6
+ * inside the JavaScript-safe range, so they are read as safe integers.
7
+ */
8
+ export const wireCents = z.number().int().safe();
@@ -0,0 +1,12 @@
1
+ import type { zPendingInvite } from '@comfyorg/ingest-types/zod';
2
+ import type { z } from 'zod';
3
+ import type { BillingResult, BillingTransport } from './billingContracts.js';
4
+ export declare const WORKSPACE_INVITES_ROUTE = "/workspace/invites";
5
+ export type WorkspaceInvite = z.infer<typeof zPendingInvite>;
6
+ export interface WorkspaceInviteCommands {
7
+ readonly listPendingInvites: () => Promise<BillingResult<readonly WorkspaceInvite[]>>;
8
+ readonly createInvite: (email: string) => Promise<BillingResult<WorkspaceInvite>>;
9
+ }
10
+ export declare function createWorkspaceInviteCommands(options: {
11
+ readonly transport: BillingTransport;
12
+ }): WorkspaceInviteCommands;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The workspace's pending invites, over the same session transport the
3
+ * billing commands use, so a host that checks out a team plan can invite the
4
+ * team without a second client. The workspace is the one the transport's
5
+ * token was minted for.
6
+ */
7
+ import { zCreateWorkspaceInviteResponse, zListInvitesResponse } from '@comfyorg/ingest-types/zod';
8
+ import { readValidatedBillingResponse } from './sharedRead.js';
9
+ export const WORKSPACE_INVITES_ROUTE = '/workspace/invites';
10
+ export function createWorkspaceInviteCommands(options) {
11
+ const { transport } = options;
12
+ async function listPendingInvites() {
13
+ const response = await readValidatedBillingResponse(transport, { method: 'GET', route: WORKSPACE_INVITES_ROUTE }, (body) => zListInvitesResponse.safeParse(body));
14
+ return response.status === 'error'
15
+ ? response
16
+ : { status: 'ok', value: response.value.data.invites };
17
+ }
18
+ async function createInvite(email) {
19
+ const body = { email };
20
+ const response = await readValidatedBillingResponse(transport, { method: 'POST', route: WORKSPACE_INVITES_ROUTE, body }, (raw) => zCreateWorkspaceInviteResponse.safeParse(raw));
21
+ return response.status === 'error'
22
+ ? response
23
+ : { status: 'ok', value: response.value.data };
24
+ }
25
+ return { listPendingInvites, createInvite };
26
+ }
@@ -2,20 +2,20 @@ import type { AccountUser } from './sessionContracts.js';
2
2
  /**
3
3
  * The identity boundary. An internal port, not a host adapter: real hosts
4
4
  * get their implementation from `@comfyorg/account-core/firebase`; tests brand a
5
- * fake through `@comfyorg/account-core/testing`. `attachIdentity` accepts only
6
- * the branded form.
5
+ * fake through `@comfyorg/account-core/testing`. `createSessionClient` accepts
6
+ * only the branded form.
7
7
  */
8
8
  export interface IdentityPort<TUser extends AccountUser = AccountUser> {
9
9
  onUserChanged: (callback: (user: TUser | null) => void) => () => void;
10
10
  }
11
11
  /**
12
- * Only the package's own identity entry (and the `./testing` seam) can mint
13
- * an identity the session client accepts. A host cannot hand in its own
14
- * provider: the symbol lives in a module the exports map never exposes.
12
+ * The brand is a compile-time gate only: `createSessionClient` rejects an
13
+ * unbranded port in the type system, and no runtime check remains. The
14
+ * package's own identity entries mint it, and `./testing` deliberately
15
+ * re-exports the minter as `createTestIdentity` so a suite can brand a fake.
15
16
  */
16
17
  export declare const identityBrand: unique symbol;
17
18
  export interface AccountIdentity<TUser extends AccountUser = AccountUser> extends IdentityPort<TUser> {
18
19
  readonly [identityBrand]: true;
19
20
  }
20
21
  export declare function brandIdentity<TUser extends AccountUser>(port: IdentityPort<TUser>): AccountIdentity<TUser>;
21
- export declare function isAccountIdentity(value: unknown): value is AccountIdentity;
@@ -1,16 +1,10 @@
1
1
  /**
2
- * Only the package's own identity entry (and the `./testing` seam) can mint
3
- * an identity the session client accepts. A host cannot hand in its own
4
- * provider: the symbol lives in a module the exports map never exposes.
2
+ * The brand is a compile-time gate only: `createSessionClient` rejects an
3
+ * unbranded port in the type system, and no runtime check remains. The
4
+ * package's own identity entries mint it, and `./testing` deliberately
5
+ * re-exports the minter as `createTestIdentity` so a suite can brand a fake.
5
6
  */
6
7
  export const identityBrand = Symbol('@comfyorg/account-core identity');
7
8
  export function brandIdentity(port) {
8
9
  return { ...port, [identityBrand]: true };
9
10
  }
10
- export function isAccountIdentity(value) {
11
- if (typeof value !== 'object' || value === null)
12
- return false;
13
- const candidate = value;
14
- return (candidate[identityBrand] === true &&
15
- typeof candidate.onUserChanged === 'function');
16
- }