@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
@@ -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,71 @@
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
1
  import type { 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
+ }, "strip", z.ZodTypeAny, {
30
+ status: "failed" | "pending" | "succeeded" | "reconciliation_needed";
31
+ id: string;
32
+ started_at: string;
33
+ completed_at?: string | undefined;
34
+ error_message?: string | undefined;
35
+ plan?: {
36
+ duration: "MONTHLY" | "ANNUAL";
37
+ slug: string;
38
+ } | undefined;
39
+ action_url?: string | undefined;
40
+ payment_intent_client_secret?: string | undefined;
41
+ amount_charged_cents?: number | undefined;
42
+ authentication_state?: "requires_action" | "processing" | "failed_retryable" | "succeeded" | "reconciliation_needed" | undefined;
43
+ credits_added?: number | undefined;
44
+ decline_reason?: "card_declined" | "insufficient_funds" | "expired_card" | "incorrect_cvc" | "authentication_required" | "authentication_failed" | "processing_error" | "payment_not_completed" | "generic" | undefined;
45
+ phase?: "in_progress" | "awaiting_payment_method" | "awaiting_invoice_payment" | undefined;
46
+ recovery_action?: "retry" | "replace_payment_method" | "authenticate_payment" | "contact_support" | undefined;
47
+ retryable?: boolean | undefined;
48
+ }, {
49
+ status: "failed" | "pending" | "succeeded" | "reconciliation_needed";
50
+ id: string;
51
+ started_at: string;
52
+ completed_at?: string | undefined;
53
+ error_message?: string | undefined;
54
+ plan?: {
55
+ duration: "MONTHLY" | "ANNUAL";
56
+ slug: string;
57
+ } | undefined;
58
+ action_url?: string | undefined;
59
+ payment_intent_client_secret?: string | undefined;
60
+ amount_charged_cents?: number | undefined;
61
+ authentication_state?: "requires_action" | "processing" | "failed_retryable" | "succeeded" | "reconciliation_needed" | undefined;
62
+ credits_added?: number | undefined;
63
+ decline_reason?: "card_declined" | "insufficient_funds" | "expired_card" | "incorrect_cvc" | "authentication_required" | "authentication_failed" | "processing_error" | "payment_not_completed" | "generic" | undefined;
64
+ phase?: "in_progress" | "awaiting_payment_method" | "awaiting_invoice_payment" | undefined;
65
+ recovery_action?: "retry" | "replace_payment_method" | "authenticate_payment" | "contact_support" | undefined;
66
+ retryable?: boolean | undefined;
67
+ }>;
68
+ export type BillingOpStatus = z.infer<typeof BillingOpStatusSchema>;
18
69
  export type BillingOperationKind = 'subscription' | 'topup' | 'cancel';
19
70
  /**
20
71
  * Where the customer completes the operation: the challenge this tab drives
@@ -52,6 +103,13 @@ export type BillingOperationIdentity = BillingPresentationState & {
52
103
  readonly observedAt: number;
53
104
  /** When the attempt began, before the command was issued; telemetry durations count from here. */
54
105
  readonly attemptStartedAt: number;
106
+ /**
107
+ * This tab issued the operation and was still waiting on its outcome when
108
+ * it adopted it: from its own command, or after a reload or a return from
109
+ * a provider page. Absent for an operation another tab issued, and for one
110
+ * this tab already saw succeed.
111
+ */
112
+ readonly awaitedHere?: true;
55
113
  };
56
114
  /**
57
115
  * The in-page challenge for an embedded presentation. `completed` and
@@ -82,9 +140,22 @@ export type FailedBillingOperation = BillingOperationIdentity & {
82
140
  readonly recoveryAction?: BillingRecoveryAction;
83
141
  readonly retryable: boolean;
84
142
  };
85
- export type BillingOperationState = PendingBillingOperation | (BillingOperationIdentity & {
143
+ /**
144
+ * What the server reports a succeeded operation did, for display only. Each
145
+ * fact is absent while the server cannot say it: a charge billed outside a
146
+ * Stripe invoice, a grant not recorded yet, or an operation that is not a
147
+ * plan change.
148
+ */
149
+ export interface BillingOperationReceipt {
150
+ readonly amountChargedCents?: number;
151
+ readonly creditsAdded?: number;
152
+ readonly plan?: NonNullable<BillingOpStatus['plan']>;
153
+ }
154
+ export type SucceededBillingOperation = BillingOperationIdentity & {
86
155
  readonly phase: 'succeeded';
87
- }) | FailedBillingOperation
156
+ readonly receipt?: BillingOperationReceipt;
157
+ };
158
+ export type BillingOperationState = PendingBillingOperation | SucceededBillingOperation | FailedBillingOperation
88
159
  /** This tab's poll budget ran out; the server may still settle the operation. */
89
160
  | (BillingOperationIdentity & {
90
161
  readonly phase: 'timed_out';
@@ -93,7 +164,10 @@ export type BillingOperationState = PendingBillingOperation | (BillingOperationI
93
164
  | (BillingOperationIdentity & {
94
165
  readonly phase: 'reconciliation_needed';
95
166
  })
96
- /** The session or workspace changed underneath it; nothing here may be attributed to the new scope. */
167
+ /**
168
+ * The session or workspace changed underneath it, or the server replaced it
169
+ * with a newer operation; nothing here may be attributed to this tab now.
170
+ */
97
171
  | (BillingOperationIdentity & {
98
172
  readonly phase: 'superseded';
99
173
  });
@@ -118,6 +192,11 @@ export type BillingOperationEvent = {
118
192
  readonly presentation: 'embedded';
119
193
  } | {
120
194
  readonly type: 'challenge_started';
195
+ }
196
+ /** The server answered a resubmit with a fresh hosted step for this same operation. */
197
+ | {
198
+ readonly type: 'action_reissued';
199
+ readonly actionUrl: string;
121
200
  } | {
122
201
  readonly type: 'challenge_settled';
123
202
  readonly outcome: 'completed' | 'failed';
@@ -125,4 +204,9 @@ export type BillingOperationEvent = {
125
204
  export declare function isTerminal(state: BillingOperationState): state is Exclude<BillingOperationState, PendingBillingOperation>;
126
205
  /** A continuation link the SDK will hand to a host: absolute and https, nothing else. */
127
206
  export declare function validateActionUrl(value: string | undefined): string | undefined;
207
+ /**
208
+ * The charge went through and the server has not recorded its credits yet;
209
+ * reading the operation again fills them in.
210
+ */
211
+ export declare function isGrantLanding(state: BillingOperationState): boolean;
128
212
  export declare function reduceBillingOperation(state: BillingOperationState, event: BillingOperationEvent): BillingOperationState;
@@ -1,3 +1,22 @@
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 { zBillingOpStatusResponse } from '@comfyorg/ingest-types/zod';
15
+ import { wireCents } from './wireCents.js';
16
+ export const BillingOpStatusSchema = zBillingOpStatusResponse.extend({
17
+ amount_charged_cents: wireCents.optional(),
18
+ credits_added: wireCents.optional()
19
+ });
1
20
  /**
2
21
  * The phases the contract defines as blocked on the customer. Neither advances
3
22
  * on its own, so an operation reporting one waits on them even before it has a
@@ -32,6 +51,7 @@ function identityOf(state) {
32
51
  scope: state.scope,
33
52
  observedAt: state.observedAt,
34
53
  attemptStartedAt: state.attemptStartedAt,
54
+ ...(state.awaitedHere ? { awaitedHere: true } : {}),
35
55
  ...presentationOf(state)
36
56
  };
37
57
  }
@@ -69,7 +89,7 @@ function nextChallenge(state, status) {
69
89
  }
70
90
  function terminalFromStatus(state, status) {
71
91
  if (status.status === 'succeeded')
72
- return withPhase(state, 'succeeded');
92
+ return succeeded(state, status);
73
93
  if (status.status === 'failed') {
74
94
  return {
75
95
  ...identityOf(state),
@@ -87,16 +107,62 @@ function terminalFromStatus(state, status) {
87
107
  }
88
108
  return undefined;
89
109
  }
110
+ function receiptOf(status) {
111
+ const receipt = {
112
+ ...(status.amount_charged_cents === undefined
113
+ ? {}
114
+ : { amountChargedCents: status.amount_charged_cents }),
115
+ ...(status.credits_added === undefined
116
+ ? {}
117
+ : { creditsAdded: status.credits_added }),
118
+ ...(status.plan === undefined ? {} : { plan: status.plan })
119
+ };
120
+ return Object.keys(receipt).length === 0 ? undefined : receipt;
121
+ }
122
+ function succeeded(state, status) {
123
+ const receipt = receiptOf(status);
124
+ return {
125
+ ...withPhase(state, 'succeeded'),
126
+ ...(receipt === undefined ? {} : { receipt })
127
+ };
128
+ }
129
+ /**
130
+ * The charge went through and the server has not recorded its credits yet;
131
+ * reading the operation again fills them in.
132
+ */
133
+ export function isGrantLanding(state) {
134
+ return (state.phase === 'succeeded' &&
135
+ state.receipt?.amountChargedCents !== undefined &&
136
+ state.receipt.creditsAdded === undefined);
137
+ }
90
138
  /**
91
139
  * A link echoed while this tab's completed challenge is still processing
92
140
  * points at that same challenge; surfacing it would ask the customer to
93
- * redo a step they just finished.
141
+ * redo a step they just finished. A checkout waiting on a card keeps the
142
+ * link a resubmit reissued, because the server stores none for that phase.
94
143
  */
95
144
  function nextActionUrl(state, status, authenticationState) {
96
- return state.challenge?.status === 'completed' &&
97
- authenticationState !== 'requires_action'
145
+ if (state.challenge?.status === 'completed' &&
146
+ authenticationState !== 'requires_action') {
147
+ return state.actionUrl;
148
+ }
149
+ const served = validateActionUrl(status.action_url);
150
+ return served === undefined && status.phase === 'awaiting_payment_method'
98
151
  ? state.actionUrl
99
- : validateActionUrl(status.action_url);
152
+ : served;
153
+ }
154
+ /**
155
+ * A retryable failure served without a reason reads as `generic`, as a
156
+ * terminal failure does, so the customer is offered the retry the state
157
+ * promises. A challenge this tab saw fail supplies its own reason.
158
+ */
159
+ function nextDeclineReason(state, status, authenticationState) {
160
+ if (authenticationState !== 'failed_retryable')
161
+ return undefined;
162
+ const known = status.decline_reason ?? state.declineReason;
163
+ if (known !== undefined || state.challenge?.status === 'failed')
164
+ return known;
165
+ return 'generic';
100
166
  }
101
167
  function reducePending(state, status) {
102
168
  if (status.id !== state.id)
@@ -108,9 +174,7 @@ function reducePending(state, status) {
108
174
  ? state.authenticationState
109
175
  : status.authentication_state;
110
176
  const actionUrl = nextActionUrl(state, status, authenticationState);
111
- const declineReason = authenticationState === 'failed_retryable'
112
- ? (status.decline_reason ?? state.declineReason)
113
- : undefined;
177
+ const declineReason = nextDeclineReason(state, status, authenticationState);
114
178
  return {
115
179
  ...state,
116
180
  challenge: nextChallenge(state, status),
@@ -183,5 +247,11 @@ export function reduceBillingOperation(state, event) {
183
247
  : state;
184
248
  case 'challenge_settled':
185
249
  return settleChallenge(state, event.outcome);
250
+ case 'action_reissued': {
251
+ const actionUrl = validateActionUrl(event.actionUrl);
252
+ return actionUrl === undefined
253
+ ? state
254
+ : { ...state, actionUrl, customerActionSeen: true };
255
+ }
186
256
  }
187
257
  }
@@ -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;
@@ -3,9 +3,35 @@ const PROCESSING_REASONS = new Set([
3
3
  'processing_error',
4
4
  'generic'
5
5
  ]);
6
+ const RECOVERY_ACTIONS = {
7
+ retry: true,
8
+ replace_payment_method: true,
9
+ authenticate_payment: true,
10
+ contact_support: true
11
+ };
12
+ /** A recovery action this build cannot act on reads as none named. */
13
+ function knownRecoveryAction(action) {
14
+ return action !== undefined && isRecoveryAction(action) ? action : undefined;
15
+ }
16
+ function isRecoveryAction(action) {
17
+ return Object.hasOwn(RECOVERY_ACTIONS, action);
18
+ }
6
19
  function stepForReason(reason) {
7
20
  return PROCESSING_REASONS.has(reason) ? 'processing_error' : 'declined';
8
21
  }
22
+ /** A completed challenge waits on the server, not on the customer. */
23
+ function awaitsVerification(state) {
24
+ const challengeOpen = state.challenge !== undefined && state.challenge.status !== 'completed';
25
+ return challengeOpen || state.actionUrl !== undefined;
26
+ }
27
+ /**
28
+ * Whether a pending payment's progress reads as a prompt rather than a
29
+ * spinner: only a hosted page the customer still has to visit asks them for
30
+ * anything. An in-page challenge is driven by the host itself.
31
+ */
32
+ export function awaitsHostedAction(state) {
33
+ return state.actionUrl !== undefined;
34
+ }
9
35
  function projectPending(state, hostStep) {
10
36
  const base = { operationId: state.id, noChargeConfirmed: false };
11
37
  if (hostStep === 'canceled')
@@ -13,17 +39,18 @@ function projectPending(state, hostStep) {
13
39
  const reason = state.declineReason ??
14
40
  (state.challenge?.status === 'failed' ? 'authentication_failed' : undefined);
15
41
  if (reason !== undefined) {
42
+ const recoveryAction = knownRecoveryAction(state.recoveryAction);
16
43
  return {
17
44
  ...base,
18
45
  step: stepForReason(reason),
19
46
  reasonKey: reason,
20
- ...(state.recoveryAction === undefined
21
- ? {}
22
- : { recoveryAction: state.recoveryAction })
47
+ ...(recoveryAction === undefined ? {} : { recoveryAction })
23
48
  };
24
49
  }
25
- const parked = state.challenge !== undefined || state.actionUrl !== undefined;
26
- return { ...base, step: parked ? 'verifying' : 'preview' };
50
+ return {
51
+ ...base,
52
+ step: awaitsVerification(state) ? 'verifying' : 'preview'
53
+ };
27
54
  }
28
55
  export function projectPaymentStep(operation, hostStep) {
29
56
  if (operation === undefined) {
@@ -35,15 +62,16 @@ export function projectPaymentStep(operation, hostStep) {
35
62
  return projectPending(operation, hostStep);
36
63
  case 'succeeded':
37
64
  return { ...base, step: 'success' };
38
- case 'failed':
65
+ case 'failed': {
66
+ const recoveryAction = knownRecoveryAction(operation.recoveryAction) ??
67
+ (operation.retryable ? undefined : 'contact_support');
39
68
  return {
40
69
  ...base,
41
70
  step: stepForReason(operation.declineReason),
42
71
  reasonKey: operation.declineReason,
43
- ...(operation.recoveryAction === undefined
44
- ? {}
45
- : { recoveryAction: operation.recoveryAction })
72
+ ...(recoveryAction === undefined ? {} : { recoveryAction })
46
73
  };
74
+ }
47
75
  case 'reconciliation_needed':
48
76
  return { ...base, step: 'processing_error', reasonKey: 'generic' };
49
77
  case 'timed_out':
@@ -5,10 +5,10 @@
5
5
  * between reads, so only those live in the readers.
6
6
  *
7
7
  * What the skeleton owns is what a reader therefore cannot get wrong on its
8
- * own: one shared request per scope, a caller's signal releasing only that
9
- * caller, the publish fence that reports a late answer as `SUPERSEDED`, and
10
- * the denial that drops a published snapshot unless it predates a change the
11
- * host reported.
8
+ * own: one shared request per scope and route, a caller's signal releasing
9
+ * only that caller, the publish fence that reports a late answer as
10
+ * `SUPERSEDED`, and the denial that drops a published snapshot unless it
11
+ * predates a change the host reported.
12
12
  */
13
13
  import type { BillingResult, BillingTransport } from './billingContracts.js';
14
14
  import type { BillingScope, BillingScopeSource } from './billingScope.js';
@@ -21,7 +21,13 @@ export interface ScopedReaderDefinition<TData, TSnapshot, TOptions extends Scope
21
21
  readonly transport: BillingTransport;
22
22
  /** Where the core learns which user, workspace, and role it runs as. */
23
23
  readonly scopeSource: BillingScopeSource;
24
- readonly route: string;
24
+ /**
25
+ * The route to request, as a function for a read whose query varies per
26
+ * call. The resolved route joins the scope in identifying an in-flight
27
+ * read, so two callers asking for different pages each get their own answer
28
+ * rather than the first one's.
29
+ */
30
+ readonly route: string | ((options: TOptions | undefined) => string);
25
31
  readonly parse: (body: unknown) => ParsedBillingBody<TData>;
26
32
  /**
27
33
  * The reader's own reading of a validated response. Returning a failure is