@comfyorg/account-core 1.0.0-alpha.0 → 1.0.0-alpha.2

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 (64) 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 +225 -19
  17. package/dist/core/billing/operationState.js +93 -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 +63 -9
  26. package/dist/core/billing/subscriptionCommands.js +46 -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/requestTimeout.d.ts +7 -0
  40. package/dist/core/requestTimeout.js +13 -0
  41. package/dist/core/session.d.ts +12 -18
  42. package/dist/core/session.js +16 -26
  43. package/dist/core/sessionContracts.d.ts +39 -2
  44. package/dist/core/sessionTokenMint.d.ts +35 -0
  45. package/dist/core/sessionTokenMint.js +222 -0
  46. package/dist/core/webSession.d.ts +27 -0
  47. package/dist/core/webSession.js +144 -0
  48. package/dist/core/webSessionFlag.d.ts +19 -0
  49. package/dist/core/webSessionFlag.js +61 -0
  50. package/dist/core/webSessionIdentity.d.ts +297 -0
  51. package/dist/core/webSessionIdentity.js +509 -0
  52. package/dist/firebase/configSource.d.ts +54 -0
  53. package/dist/firebase/configSource.js +111 -0
  54. package/dist/firebase/index.d.ts +71 -8
  55. package/dist/firebase/index.js +236 -23
  56. package/dist/firebase/popupWatch.d.ts +42 -0
  57. package/dist/firebase/popupWatch.js +140 -0
  58. package/dist/testing.d.ts +38 -0
  59. package/dist/testing.js +116 -0
  60. package/dist/web/crossTabRefresh.d.ts +4 -2
  61. package/dist/web/crossTabRefresh.js +13 -0
  62. package/dist/workspaceLink.d.ts +45 -0
  63. package/dist/workspaceLink.js +78 -0
  64. package/package.json +30 -6
@@ -9,7 +9,9 @@ export const OPERATION_POLL_TIMING = {
9
9
  maxMs: 8_000,
10
10
  multiplier: 1.5,
11
11
  /** An operation parked on the customer is checked on a slow, flat cadence. */
12
- parkedMs: 30_000
12
+ parkedMs: 30_000,
13
+ /** Fast backoff for an actionless blocked wait: 20 turns of the 3 s PI status cache. */
14
+ actionDiscoveryMs: 60_000
13
15
  };
14
16
  export const OPERATION_POLL_BUDGET = {
15
17
  defaultMs: 120_000,
@@ -18,6 +20,40 @@ export const OPERATION_POLL_BUDGET = {
18
20
  /** Once the customer is involved, a bank challenge can idle for hours. */
19
21
  customerActionMs: 23 * 60 * 60_000
20
22
  };
23
+ /**
24
+ * The one "customer can act here" rule both billing rails park on: a
25
+ * retryable failure to retry, a hosted page to visit, or an embedded
26
+ * challenge the server still says is required. A non-retryable decline never
27
+ * reaches it; the operation is terminal by then.
28
+ */
29
+ export function customerCanActHere(hold) {
30
+ if (hold.authenticationState === 'failed_retryable')
31
+ return true;
32
+ if (hold.offersHostedPage)
33
+ return true;
34
+ return (hold.authenticationState === 'requires_action' &&
35
+ (hold.challenge === 'required' || hold.challenge === 'in_progress'));
36
+ }
37
+ /**
38
+ * The SDK renders one presentation: a hosted operation offers only its page,
39
+ * an embedded one only its challenge. The cloud app's
40
+ * `legacyOperationActionHold` names the rows where the two surfaces differ.
41
+ */
42
+ export function pendingOperationActionHold(state) {
43
+ return state.presentation === 'hosted'
44
+ ? {
45
+ authenticationState: state.authenticationState,
46
+ offersHostedPage: state.actionUrl !== undefined
47
+ }
48
+ : {
49
+ authenticationState: state.authenticationState,
50
+ offersHostedPage: false,
51
+ challenge: state.challenge?.status
52
+ };
53
+ }
54
+ function customerCanAct(state) {
55
+ return customerCanActHere(pendingOperationActionHold(state));
56
+ }
21
57
  /**
22
58
  * Waiting on the customer, not on the backend: a challenge to complete
23
59
  * elsewhere, a hosted page to finish, a phase the server reports as blocked on
@@ -25,16 +61,41 @@ export const OPERATION_POLL_BUDGET = {
25
61
  * challenge completes the state reads processing and nothing waits on the
26
62
  * customer anymore.
27
63
  */
28
- export function isParkedOnCustomer(state) {
64
+ function isWaitingOnCustomer(state) {
29
65
  return (state.authenticationState === 'requires_action' ||
30
66
  state.actionUrl !== undefined ||
31
67
  isBlockedOnCustomerPhase(state.serverPhase) ||
32
68
  (state.authenticationState === 'failed_retryable' &&
33
69
  state.customerActionSeen));
34
70
  }
35
- export function nextPollDelayMs(state, previousDelayMs) {
71
+ /**
72
+ * Parked only while the customer can act here. The server can report a
73
+ * blocked phase and a client secret before its cached `authentication_state`
74
+ * catches up, and the slow cadence would then hold a screen with no action.
75
+ */
76
+ export function isParkedOnCustomer(state) {
77
+ return customerCanAct(state) && isWaitingOnCustomer(state);
78
+ }
79
+ /**
80
+ * Blocked on the customer with nothing to offer them yet: either the action
81
+ * is still on its way, or it belongs to someone else (a member without
82
+ * billing permission, a tab without embedded checkout).
83
+ */
84
+ export function isWaitingOnCustomerWithoutAction(state) {
85
+ return !customerCanAct(state) && isWaitingOnCustomer(state);
86
+ }
87
+ /**
88
+ * `waitedWithoutActionMs` is how long the operation has continuously been
89
+ * {@link isWaitingOnCustomerWithoutAction}; past the discovery window it
90
+ * parks too, so an action that never arrives here costs the slow cadence.
91
+ */
92
+ export function nextPollDelayMs(state, previousDelayMs, waitedWithoutActionMs = 0) {
36
93
  if (isParkedOnCustomer(state))
37
94
  return OPERATION_POLL_TIMING.parkedMs;
95
+ if (isWaitingOnCustomerWithoutAction(state) &&
96
+ waitedWithoutActionMs >= OPERATION_POLL_TIMING.actionDiscoveryMs) {
97
+ return OPERATION_POLL_TIMING.parkedMs;
98
+ }
38
99
  return Math.min((previousDelayMs ?? OPERATION_POLL_TIMING.initialMs) *
39
100
  OPERATION_POLL_TIMING.multiplier, OPERATION_POLL_TIMING.maxMs);
40
101
  }
@@ -1,20 +1,193 @@
1
- /**
2
- * One billing operation as the SDK sees it: a discriminated union over the
3
- * lifecycle a `billing_op_id` moves through, and the pure transition that
4
- * advances it. The identity is fixed at adoption and survives every
5
- * presentation change, so switching a checkout from the embedded challenge
6
- * to the hosted page (or back) is a transition on the same operation, never
7
- * a replacement one.
8
- *
9
- * The transition consumes the generated `BillingOpStatusResponse`. It reads
10
- * the coded `decline_reason` and `recovery_action` and never `error_message`,
11
- * so no server or payment-provider text can reach a consumer through this
12
- * state.
13
- */
14
- import type { zBillingOpStatusResponse } from '@comfyorg/ingest-types/zod';
15
- import type { z } from 'zod';
1
+ import { z } from 'zod';
16
2
  import type { BillingScope } from './billingScope.js';
17
- export type BillingOpStatus = z.infer<typeof zBillingOpStatusResponse>;
3
+ export declare const BillingOpStatusSchema: z.ZodObject<{
4
+ action_url: z.ZodOptional<z.ZodString>;
5
+ authentication_state: z.ZodOptional<z.ZodEnum<["requires_action", "processing", "failed_retryable", "succeeded", "reconciliation_needed"]>>;
6
+ completed_at: z.ZodOptional<z.ZodString>;
7
+ decline_reason: z.ZodOptional<z.ZodEnum<["card_declined", "insufficient_funds", "expired_card", "incorrect_cvc", "authentication_required", "authentication_failed", "processing_error", "payment_not_completed", "generic"]>>;
8
+ error_message: z.ZodOptional<z.ZodString>;
9
+ id: z.ZodString;
10
+ payment_intent_client_secret: z.ZodOptional<z.ZodString>;
11
+ phase: z.ZodOptional<z.ZodEnum<["awaiting_payment_method", "awaiting_invoice_payment", "in_progress"]>>;
12
+ plan: z.ZodOptional<z.ZodObject<{
13
+ duration: z.ZodEnum<["MONTHLY", "ANNUAL"]>;
14
+ slug: z.ZodString;
15
+ }, "strip", z.ZodTypeAny, {
16
+ duration: "MONTHLY" | "ANNUAL";
17
+ slug: string;
18
+ }, {
19
+ duration: "MONTHLY" | "ANNUAL";
20
+ slug: string;
21
+ }>>;
22
+ recovery_action: z.ZodOptional<z.ZodEnum<["retry", "replace_payment_method", "authenticate_payment", "contact_support"]>>;
23
+ retryable: z.ZodOptional<z.ZodBoolean>;
24
+ started_at: z.ZodString;
25
+ status: z.ZodEnum<["pending", "succeeded", "failed", "reconciliation_needed"]>;
26
+ } & {
27
+ amount_charged_cents: z.ZodOptional<z.ZodNumber>;
28
+ credits_added: z.ZodOptional<z.ZodNumber>;
29
+ charge_breakdown: z.ZodOptional<z.ZodObject<{
30
+ currency: z.ZodString;
31
+ prorated: z.ZodBoolean;
32
+ } & {
33
+ amount_charged_cents: z.ZodNumber;
34
+ reasons: z.ZodArray<z.ZodObject<{
35
+ kind: z.ZodEnum<["promo_code", "subscription_discount", "account_balance"]>;
36
+ } & {
37
+ amount_cents: z.ZodNumber;
38
+ discount: z.ZodOptional<z.ZodObject<{
39
+ code: z.ZodString;
40
+ duration: z.ZodOptional<z.ZodEnum<["once", "repeating", "forever"]>>;
41
+ kind: z.ZodEnum<["plan", "promotion"]>;
42
+ name: z.ZodOptional<z.ZodString>;
43
+ } & {
44
+ amount_off_cents: z.ZodOptional<z.ZodNumber>;
45
+ duration_in_months: z.ZodOptional<z.ZodNumber>;
46
+ }, "strip", z.ZodTypeAny, {
47
+ code: string;
48
+ kind: "plan" | "promotion";
49
+ name?: string | undefined;
50
+ amount_off_cents?: number | undefined;
51
+ duration?: "once" | "repeating" | "forever" | undefined;
52
+ duration_in_months?: number | undefined;
53
+ }, {
54
+ code: string;
55
+ kind: "plan" | "promotion";
56
+ name?: string | undefined;
57
+ amount_off_cents?: number | undefined;
58
+ duration?: "once" | "repeating" | "forever" | undefined;
59
+ duration_in_months?: number | undefined;
60
+ }>>;
61
+ }, "strip", z.ZodTypeAny, {
62
+ amount_cents: number;
63
+ kind: "promo_code" | "subscription_discount" | "account_balance";
64
+ discount?: {
65
+ code: string;
66
+ kind: "plan" | "promotion";
67
+ name?: string | undefined;
68
+ amount_off_cents?: number | undefined;
69
+ duration?: "once" | "repeating" | "forever" | undefined;
70
+ duration_in_months?: number | undefined;
71
+ } | undefined;
72
+ }, {
73
+ amount_cents: number;
74
+ kind: "promo_code" | "subscription_discount" | "account_balance";
75
+ discount?: {
76
+ code: string;
77
+ kind: "plan" | "promotion";
78
+ name?: string | undefined;
79
+ amount_off_cents?: number | undefined;
80
+ duration?: "once" | "repeating" | "forever" | undefined;
81
+ duration_in_months?: number | undefined;
82
+ } | undefined;
83
+ }>, "many">;
84
+ }, "strip", z.ZodTypeAny, {
85
+ currency: string;
86
+ amount_charged_cents: number;
87
+ prorated: boolean;
88
+ reasons: {
89
+ amount_cents: number;
90
+ kind: "promo_code" | "subscription_discount" | "account_balance";
91
+ discount?: {
92
+ code: string;
93
+ kind: "plan" | "promotion";
94
+ name?: string | undefined;
95
+ amount_off_cents?: number | undefined;
96
+ duration?: "once" | "repeating" | "forever" | undefined;
97
+ duration_in_months?: number | undefined;
98
+ } | undefined;
99
+ }[];
100
+ }, {
101
+ currency: string;
102
+ amount_charged_cents: number;
103
+ prorated: boolean;
104
+ reasons: {
105
+ amount_cents: number;
106
+ kind: "promo_code" | "subscription_discount" | "account_balance";
107
+ discount?: {
108
+ code: string;
109
+ kind: "plan" | "promotion";
110
+ name?: string | undefined;
111
+ amount_off_cents?: number | undefined;
112
+ duration?: "once" | "repeating" | "forever" | undefined;
113
+ duration_in_months?: number | undefined;
114
+ } | undefined;
115
+ }[];
116
+ }>>;
117
+ }, "strip", z.ZodTypeAny, {
118
+ status: "failed" | "pending" | "succeeded" | "reconciliation_needed";
119
+ id: string;
120
+ started_at: string;
121
+ completed_at?: string | undefined;
122
+ error_message?: string | undefined;
123
+ plan?: {
124
+ duration: "MONTHLY" | "ANNUAL";
125
+ slug: string;
126
+ } | undefined;
127
+ action_url?: string | undefined;
128
+ payment_intent_client_secret?: string | undefined;
129
+ amount_charged_cents?: number | undefined;
130
+ authentication_state?: "requires_action" | "processing" | "failed_retryable" | "succeeded" | "reconciliation_needed" | undefined;
131
+ charge_breakdown?: {
132
+ currency: string;
133
+ amount_charged_cents: number;
134
+ prorated: boolean;
135
+ reasons: {
136
+ amount_cents: number;
137
+ kind: "promo_code" | "subscription_discount" | "account_balance";
138
+ discount?: {
139
+ code: string;
140
+ kind: "plan" | "promotion";
141
+ name?: string | undefined;
142
+ amount_off_cents?: number | undefined;
143
+ duration?: "once" | "repeating" | "forever" | undefined;
144
+ duration_in_months?: number | undefined;
145
+ } | undefined;
146
+ }[];
147
+ } | undefined;
148
+ credits_added?: number | undefined;
149
+ decline_reason?: "card_declined" | "insufficient_funds" | "expired_card" | "incorrect_cvc" | "authentication_required" | "authentication_failed" | "processing_error" | "payment_not_completed" | "generic" | undefined;
150
+ phase?: "in_progress" | "awaiting_payment_method" | "awaiting_invoice_payment" | undefined;
151
+ recovery_action?: "retry" | "replace_payment_method" | "authenticate_payment" | "contact_support" | undefined;
152
+ retryable?: boolean | undefined;
153
+ }, {
154
+ status: "failed" | "pending" | "succeeded" | "reconciliation_needed";
155
+ id: string;
156
+ started_at: string;
157
+ completed_at?: string | undefined;
158
+ error_message?: string | undefined;
159
+ plan?: {
160
+ duration: "MONTHLY" | "ANNUAL";
161
+ slug: string;
162
+ } | undefined;
163
+ action_url?: string | undefined;
164
+ payment_intent_client_secret?: string | undefined;
165
+ amount_charged_cents?: number | undefined;
166
+ authentication_state?: "requires_action" | "processing" | "failed_retryable" | "succeeded" | "reconciliation_needed" | undefined;
167
+ charge_breakdown?: {
168
+ currency: string;
169
+ amount_charged_cents: number;
170
+ prorated: boolean;
171
+ reasons: {
172
+ amount_cents: number;
173
+ kind: "promo_code" | "subscription_discount" | "account_balance";
174
+ discount?: {
175
+ code: string;
176
+ kind: "plan" | "promotion";
177
+ name?: string | undefined;
178
+ amount_off_cents?: number | undefined;
179
+ duration?: "once" | "repeating" | "forever" | undefined;
180
+ duration_in_months?: number | undefined;
181
+ } | undefined;
182
+ }[];
183
+ } | undefined;
184
+ credits_added?: number | undefined;
185
+ decline_reason?: "card_declined" | "insufficient_funds" | "expired_card" | "incorrect_cvc" | "authentication_required" | "authentication_failed" | "processing_error" | "payment_not_completed" | "generic" | undefined;
186
+ phase?: "in_progress" | "awaiting_payment_method" | "awaiting_invoice_payment" | undefined;
187
+ recovery_action?: "retry" | "replace_payment_method" | "authenticate_payment" | "contact_support" | undefined;
188
+ retryable?: boolean | undefined;
189
+ }>;
190
+ export type BillingOpStatus = z.infer<typeof BillingOpStatusSchema>;
18
191
  export type BillingOperationKind = 'subscription' | 'topup' | 'cancel';
19
192
  /**
20
193
  * Where the customer completes the operation: the challenge this tab drives
@@ -52,6 +225,13 @@ export type BillingOperationIdentity = BillingPresentationState & {
52
225
  readonly observedAt: number;
53
226
  /** When the attempt began, before the command was issued; telemetry durations count from here. */
54
227
  readonly attemptStartedAt: number;
228
+ /**
229
+ * This tab issued the operation and was still waiting on its outcome when
230
+ * it adopted it: from its own command, or after a reload or a return from
231
+ * a provider page. Absent for an operation another tab issued, and for one
232
+ * this tab already saw succeed.
233
+ */
234
+ readonly awaitedHere?: true;
55
235
  };
56
236
  /**
57
237
  * The in-page challenge for an embedded presentation. `completed` and
@@ -82,9 +262,22 @@ export type FailedBillingOperation = BillingOperationIdentity & {
82
262
  readonly recoveryAction?: BillingRecoveryAction;
83
263
  readonly retryable: boolean;
84
264
  };
85
- export type BillingOperationState = PendingBillingOperation | (BillingOperationIdentity & {
265
+ /**
266
+ * What the server reports a succeeded operation did, for display only. Each
267
+ * fact is absent while the server cannot say it: a charge billed outside a
268
+ * Stripe invoice, a grant not recorded yet, or an operation that is not a
269
+ * plan change.
270
+ */
271
+ export interface BillingOperationReceipt {
272
+ readonly amountChargedCents?: number;
273
+ readonly creditsAdded?: number;
274
+ readonly plan?: NonNullable<BillingOpStatus['plan']>;
275
+ }
276
+ export type SucceededBillingOperation = BillingOperationIdentity & {
86
277
  readonly phase: 'succeeded';
87
- }) | FailedBillingOperation
278
+ readonly receipt?: BillingOperationReceipt;
279
+ };
280
+ export type BillingOperationState = PendingBillingOperation | SucceededBillingOperation | FailedBillingOperation
88
281
  /** This tab's poll budget ran out; the server may still settle the operation. */
89
282
  | (BillingOperationIdentity & {
90
283
  readonly phase: 'timed_out';
@@ -93,7 +286,10 @@ export type BillingOperationState = PendingBillingOperation | (BillingOperationI
93
286
  | (BillingOperationIdentity & {
94
287
  readonly phase: 'reconciliation_needed';
95
288
  })
96
- /** The session or workspace changed underneath it; nothing here may be attributed to the new scope. */
289
+ /**
290
+ * The session or workspace changed underneath it, or the server replaced it
291
+ * with a newer operation; nothing here may be attributed to this tab now.
292
+ */
97
293
  | (BillingOperationIdentity & {
98
294
  readonly phase: 'superseded';
99
295
  });
@@ -118,6 +314,11 @@ export type BillingOperationEvent = {
118
314
  readonly presentation: 'embedded';
119
315
  } | {
120
316
  readonly type: 'challenge_started';
317
+ }
318
+ /** The server answered a resubmit with a fresh hosted step for this same operation. */
319
+ | {
320
+ readonly type: 'action_reissued';
321
+ readonly actionUrl: string;
121
322
  } | {
122
323
  readonly type: 'challenge_settled';
123
324
  readonly outcome: 'completed' | 'failed';
@@ -125,4 +326,9 @@ export type BillingOperationEvent = {
125
326
  export declare function isTerminal(state: BillingOperationState): state is Exclude<BillingOperationState, PendingBillingOperation>;
126
327
  /** A continuation link the SDK will hand to a host: absolute and https, nothing else. */
127
328
  export declare function validateActionUrl(value: string | undefined): string | undefined;
329
+ /**
330
+ * The charge went through and the server has not recorded its credits yet;
331
+ * reading the operation again fills them in.
332
+ */
333
+ export declare function isGrantLanding(state: BillingOperationState): boolean;
128
334
  export declare function reduceBillingOperation(state: BillingOperationState, event: BillingOperationEvent): BillingOperationState;
@@ -1,3 +1,37 @@
1
+ /**
2
+ * One billing operation as the SDK sees it: a discriminated union over the
3
+ * lifecycle a `billing_op_id` moves through, and the pure transition that
4
+ * advances it. The identity is fixed at adoption and survives every
5
+ * presentation change, so switching a checkout from the embedded challenge
6
+ * to the hosted page (or back) is a transition on the same operation, never
7
+ * a replacement one.
8
+ *
9
+ * The transition consumes the generated `BillingOpStatusResponse`. It reads
10
+ * the coded `decline_reason` and `recovery_action` and never `error_message`,
11
+ * so no server or payment-provider text can reach a consumer through this
12
+ * state.
13
+ */
14
+ import { zBillingOpChargeBreakdown, zBillingOpChargeReason, zBillingOpStatusResponse, zSubscriptionDiscount } from '@comfyorg/ingest-types/zod';
15
+ import { z } from 'zod';
16
+ import { wireCents } from './wireCents.js';
17
+ export const BillingOpStatusSchema = zBillingOpStatusResponse.extend({
18
+ amount_charged_cents: wireCents.optional(),
19
+ credits_added: wireCents.optional(),
20
+ charge_breakdown: zBillingOpChargeBreakdown
21
+ .extend({
22
+ amount_charged_cents: wireCents,
23
+ reasons: z.array(zBillingOpChargeReason.extend({
24
+ amount_cents: wireCents,
25
+ discount: zSubscriptionDiscount
26
+ .extend({
27
+ amount_off_cents: wireCents.optional(),
28
+ duration_in_months: wireCents.optional()
29
+ })
30
+ .optional()
31
+ }))
32
+ })
33
+ .optional()
34
+ });
1
35
  /**
2
36
  * The phases the contract defines as blocked on the customer. Neither advances
3
37
  * on its own, so an operation reporting one waits on them even before it has a
@@ -32,6 +66,7 @@ function identityOf(state) {
32
66
  scope: state.scope,
33
67
  observedAt: state.observedAt,
34
68
  attemptStartedAt: state.attemptStartedAt,
69
+ ...(state.awaitedHere ? { awaitedHere: true } : {}),
35
70
  ...presentationOf(state)
36
71
  };
37
72
  }
@@ -69,7 +104,7 @@ function nextChallenge(state, status) {
69
104
  }
70
105
  function terminalFromStatus(state, status) {
71
106
  if (status.status === 'succeeded')
72
- return withPhase(state, 'succeeded');
107
+ return succeeded(state, status);
73
108
  if (status.status === 'failed') {
74
109
  return {
75
110
  ...identityOf(state),
@@ -87,16 +122,62 @@ function terminalFromStatus(state, status) {
87
122
  }
88
123
  return undefined;
89
124
  }
125
+ function receiptOf(status) {
126
+ const receipt = {
127
+ ...(status.amount_charged_cents === undefined
128
+ ? {}
129
+ : { amountChargedCents: status.amount_charged_cents }),
130
+ ...(status.credits_added === undefined
131
+ ? {}
132
+ : { creditsAdded: status.credits_added }),
133
+ ...(status.plan === undefined ? {} : { plan: status.plan })
134
+ };
135
+ return Object.keys(receipt).length === 0 ? undefined : receipt;
136
+ }
137
+ function succeeded(state, status) {
138
+ const receipt = receiptOf(status);
139
+ return {
140
+ ...withPhase(state, 'succeeded'),
141
+ ...(receipt === undefined ? {} : { receipt })
142
+ };
143
+ }
144
+ /**
145
+ * The charge went through and the server has not recorded its credits yet;
146
+ * reading the operation again fills them in.
147
+ */
148
+ export function isGrantLanding(state) {
149
+ return (state.phase === 'succeeded' &&
150
+ state.receipt?.amountChargedCents !== undefined &&
151
+ state.receipt.creditsAdded === undefined);
152
+ }
90
153
  /**
91
154
  * A link echoed while this tab's completed challenge is still processing
92
155
  * points at that same challenge; surfacing it would ask the customer to
93
- * redo a step they just finished.
156
+ * redo a step they just finished. A checkout waiting on a card keeps the
157
+ * link a resubmit reissued, because the server stores none for that phase.
94
158
  */
95
159
  function nextActionUrl(state, status, authenticationState) {
96
- return state.challenge?.status === 'completed' &&
97
- authenticationState !== 'requires_action'
160
+ if (state.challenge?.status === 'completed' &&
161
+ authenticationState !== 'requires_action') {
162
+ return state.actionUrl;
163
+ }
164
+ const served = validateActionUrl(status.action_url);
165
+ return served === undefined && status.phase === 'awaiting_payment_method'
98
166
  ? state.actionUrl
99
- : validateActionUrl(status.action_url);
167
+ : served;
168
+ }
169
+ /**
170
+ * A retryable failure served without a reason reads as `generic`, as a
171
+ * terminal failure does, so the customer is offered the retry the state
172
+ * promises. A challenge this tab saw fail supplies its own reason.
173
+ */
174
+ function nextDeclineReason(state, status, authenticationState) {
175
+ if (authenticationState !== 'failed_retryable')
176
+ return undefined;
177
+ const known = status.decline_reason ?? state.declineReason;
178
+ if (known !== undefined || state.challenge?.status === 'failed')
179
+ return known;
180
+ return 'generic';
100
181
  }
101
182
  function reducePending(state, status) {
102
183
  if (status.id !== state.id)
@@ -108,9 +189,7 @@ function reducePending(state, status) {
108
189
  ? state.authenticationState
109
190
  : status.authentication_state;
110
191
  const actionUrl = nextActionUrl(state, status, authenticationState);
111
- const declineReason = authenticationState === 'failed_retryable'
112
- ? (status.decline_reason ?? state.declineReason)
113
- : undefined;
192
+ const declineReason = nextDeclineReason(state, status, authenticationState);
114
193
  return {
115
194
  ...state,
116
195
  challenge: nextChallenge(state, status),
@@ -183,5 +262,11 @@ export function reduceBillingOperation(state, event) {
183
262
  : state;
184
263
  case 'challenge_settled':
185
264
  return settleChallenge(state, event.outcome);
265
+ case 'action_reissued': {
266
+ const actionUrl = validateActionUrl(event.actionUrl);
267
+ return actionUrl === undefined
268
+ ? state
269
+ : { ...state, actionUrl, customerActionSeen: true };
270
+ }
186
271
  }
187
272
  }
@@ -3,13 +3,18 @@
3
3
  * a host may override — except the safety line, which only the projection's
4
4
  * `noChargeConfirmed` may ever unlock.
5
5
  */
6
+ import type { BillingDeclineReason, BillingRecoveryAction } from './operationState.js';
6
7
  import type { PaymentProjection, PaymentReasonKey, PaymentStep } from './paymentProjection.js';
7
- export type PaymentCopyKey = `billing.step.${PaymentStep}.header` | `billing.step.${PaymentStep}.body` | `billing.reason.${PaymentReasonKey}` | 'billing.action.retry' | 'billing.action.continue_verification' | 'billing.safety.nothing_was_charged';
8
+ export type PaymentCopyKey = `billing.step.${PaymentStep}.header` | `billing.step.${PaymentStep}.body` | `billing.reason.${PaymentReasonKey}` | `billing.recovery.${BillingRecoveryAction}` | 'billing.action.retry' | 'billing.action.replace_payment_method' | 'billing.action.contact_support' | 'billing.action.continue_verification' | 'billing.safety.nothing_was_charged';
8
9
  declare const SAFETY_KEY = "billing.safety.nothing_was_charged";
9
10
  export declare const DEFAULT_PAYMENT_COPY: Readonly<Record<PaymentCopyKey, string>>;
11
+ /** The host's toast detail for a payment the server declined. */
12
+ export type DeclineDetailKey = 'insufficientFundsDetail' | 'expiredCardDetail' | 'incorrectCvcDetail' | 'authenticationFailedDetail' | 'processingErrorDetail' | 'paymentDeclinedDetail';
13
+ export declare function declineDetailKey(reason: BillingDeclineReason): DeclineDetailKey;
10
14
  export declare function createPaymentCopy(overrides?: Partial<Record<PaymentCopyKey, string>>): Readonly<Record<PaymentCopyKey, string>>;
11
15
  export interface PaymentCopyKeys {
12
16
  readonly header: PaymentCopyKey;
17
+ /** The server's recovery line when it names one, else the step's own body. */
13
18
  readonly body: PaymentCopyKey;
14
19
  readonly reason?: PaymentCopyKey;
15
20
  readonly safety?: typeof SAFETY_KEY;
@@ -24,19 +24,52 @@ export const DEFAULT_PAYMENT_COPY = {
24
24
  'billing.reason.incorrect_cvc': 'The security code did not match.',
25
25
  'billing.reason.authentication_required': 'Your bank requires authentication.',
26
26
  'billing.reason.authentication_failed': 'Authentication with your bank did not complete.',
27
+ 'billing.reason.payment_not_completed': 'The payment was not completed.',
27
28
  'billing.reason.processing_error': 'The payment could not be processed right now.',
29
+ 'billing.recovery.retry': 'Please try again.',
30
+ 'billing.recovery.replace_payment_method': 'Try again with a different payment method.',
31
+ 'billing.recovery.authenticate_payment': 'Try again and complete the verification your bank asks for.',
32
+ 'billing.recovery.contact_support': 'This payment cannot be retried. Contact support@comfy.org and we will help you finish it.',
28
33
  'billing.action.retry': 'Try again',
34
+ 'billing.action.replace_payment_method': 'Use a different payment method',
35
+ 'billing.action.contact_support': 'Contact support',
29
36
  'billing.action.continue_verification': 'Continue verification',
30
37
  [SAFETY_KEY]: 'Nothing was charged.'
31
38
  };
39
+ export function declineDetailKey(reason) {
40
+ switch (reason) {
41
+ case 'insufficient_funds':
42
+ return 'insufficientFundsDetail';
43
+ case 'expired_card':
44
+ return 'expiredCardDetail';
45
+ case 'incorrect_cvc':
46
+ return 'incorrectCvcDetail';
47
+ case 'authentication_required':
48
+ case 'authentication_failed':
49
+ case 'payment_not_completed':
50
+ return 'authenticationFailedDetail';
51
+ case 'processing_error':
52
+ return 'processingErrorDetail';
53
+ case 'card_declined':
54
+ case 'generic':
55
+ return 'paymentDeclinedDetail';
56
+ }
57
+ }
32
58
  export function createPaymentCopy(overrides = {}) {
33
59
  const { [SAFETY_KEY]: _protected, ...safe } = overrides;
34
60
  return { ...DEFAULT_PAYMENT_COPY, ...safe };
35
61
  }
62
+ function recoveryCopyKey(action) {
63
+ if (action === undefined)
64
+ return undefined;
65
+ const key = `billing.recovery.${action}`;
66
+ return key in DEFAULT_PAYMENT_COPY ? key : undefined;
67
+ }
36
68
  export function paymentCopyKeys(projection) {
37
69
  return {
38
70
  header: `billing.step.${projection.step}.header`,
39
- body: `billing.step.${projection.step}.body`,
71
+ body: recoveryCopyKey(projection.recoveryAction) ??
72
+ `billing.step.${projection.step}.body`,
40
73
  ...(projection.reasonKey === undefined
41
74
  ? {}
42
75
  : { reason: `billing.reason.${projection.reasonKey}` }),
@@ -4,9 +4,10 @@
4
4
  * operation exists; it never advances the machine itself.
5
5
  *
6
6
  * A server verdict outranks anything the host reports: a customer who backed
7
- * out of a page after the charge went through still sees the success.
7
+ * out of a page after the charge went through still sees the success. A
8
+ * challenge this tab completed is not offered again while the server settles.
8
9
  */
9
- import type { BillingDeclineReason, BillingOperationState, BillingRecoveryAction } from './operationState.js';
10
+ import type { BillingDeclineReason, BillingOperationState, BillingRecoveryAction, PendingBillingOperation } from './operationState.js';
10
11
  export type PaymentStep = 'select' | 'preview' | 'verifying' | 'canceled' | 'declined' | 'processing_error'
11
12
  /** Reserved: the contract cannot yet tell "paid, entitlement pending" from "needs a human". */
12
13
  | 'payment_received_hold' | 'success';
@@ -22,6 +23,11 @@ export type PaymentReasonKey = BillingDeclineReason | 'checkout_expired' | 'gene
22
23
  export interface PaymentProjection {
23
24
  readonly step: PaymentStep;
24
25
  readonly reasonKey?: PaymentReasonKey;
26
+ /**
27
+ * What the server tells the customer to do next. A failed operation the
28
+ * server marks non-retryable without naming a recovery reads as
29
+ * `contact_support`, so no dead end ever offers a retry.
30
+ */
25
31
  readonly recoveryAction?: BillingRecoveryAction;
26
32
  /** Present whenever an operation backs the projection; the id support can act on. */
27
33
  readonly operationId?: string;
@@ -31,4 +37,10 @@ export interface PaymentProjection {
31
37
  */
32
38
  readonly noChargeConfirmed: boolean;
33
39
  }
40
+ /**
41
+ * Whether a pending payment's progress reads as a prompt rather than a
42
+ * spinner: only a hosted page the customer still has to visit asks them for
43
+ * anything. An in-page challenge is driven by the host itself.
44
+ */
45
+ export declare function awaitsHostedAction(state: PendingBillingOperation): boolean;
34
46
  export declare function projectPaymentStep(operation: BillingOperationState | undefined, hostStep: HostPaymentStep): PaymentProjection;