@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.
- package/dist/core/billing/billingContracts.d.ts +29 -14
- package/dist/core/billing/billingErrorBody.d.ts +8 -4
- package/dist/core/billing/billingErrorBody.js +14 -4
- package/dist/core/billing/credentialedTransport.d.ts +23 -7
- package/dist/core/billing/credentialedTransport.js +72 -8
- package/dist/core/billing/events.d.ts +47 -0
- package/dist/core/billing/events.js +43 -0
- package/dist/core/billing/index.d.ts +16 -11
- package/dist/core/billing/index.js +8 -6
- package/dist/core/billing/operationLifecycle.d.ts +34 -1
- package/dist/core/billing/operationLifecycle.js +146 -67
- package/dist/core/billing/operationPointer.d.ts +27 -2
- package/dist/core/billing/operationPointer.js +36 -25
- package/dist/core/billing/operationPolicy.d.ts +44 -7
- package/dist/core/billing/operationPolicy.js +64 -3
- package/dist/core/billing/operationState.d.ts +102 -18
- package/dist/core/billing/operationState.js +78 -8
- package/dist/core/billing/paymentCopy.d.ts +6 -1
- package/dist/core/billing/paymentCopy.js +34 -1
- package/dist/core/billing/paymentProjection.d.ts +14 -2
- package/dist/core/billing/paymentProjection.js +37 -9
- package/dist/core/billing/scopedReader.d.ts +11 -5
- package/dist/core/billing/scopedReader.js +20 -9
- package/dist/core/billing/sharedRead.js +4 -2
- package/dist/core/billing/subscriptionCommands.d.ts +37 -9
- package/dist/core/billing/subscriptionCommands.js +42 -30
- package/dist/core/billing/topup.d.ts +26 -0
- package/dist/core/billing/topup.js +36 -2
- package/dist/core/billing/wireCents.d.ts +8 -0
- package/dist/core/billing/wireCents.js +8 -0
- package/dist/core/billing/workspaceInvites.d.ts +12 -0
- package/dist/core/billing/workspaceInvites.js +26 -0
- package/dist/core/identity.d.ts +6 -6
- package/dist/core/identity.js +4 -10
- package/dist/core/lazyIdentity.d.ts +31 -0
- package/dist/core/lazyIdentity.js +96 -0
- package/dist/core/requestAuth.d.ts +30 -0
- package/dist/core/requestAuth.js +41 -0
- package/dist/core/session.d.ts +12 -18
- package/dist/core/session.js +16 -26
- package/dist/core/sessionContracts.d.ts +39 -2
- package/dist/core/sessionTokenMint.d.ts +33 -0
- package/dist/core/sessionTokenMint.js +205 -0
- package/dist/core/webSession.d.ts +25 -0
- package/dist/core/webSession.js +137 -0
- package/dist/core/webSessionFlag.d.ts +20 -0
- package/dist/core/webSessionFlag.js +62 -0
- package/dist/core/webSessionIdentity.d.ts +291 -0
- package/dist/core/webSessionIdentity.js +496 -0
- package/dist/firebase/configSource.d.ts +54 -0
- package/dist/firebase/configSource.js +111 -0
- package/dist/firebase/index.d.ts +71 -8
- package/dist/firebase/index.js +236 -23
- package/dist/firebase/popupWatch.d.ts +42 -0
- package/dist/firebase/popupWatch.js +140 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.js +116 -0
- package/dist/web/crossTabRefresh.d.ts +4 -2
- package/dist/web/crossTabRefresh.js +13 -0
- package/dist/workspaceLink.d.ts +45 -0
- package/dist/workspaceLink.js +78 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
:
|
|
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 =
|
|
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:
|
|
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
|
-
...(
|
|
21
|
-
? {}
|
|
22
|
-
: { recoveryAction: state.recoveryAction })
|
|
47
|
+
...(recoveryAction === undefined ? {} : { recoveryAction })
|
|
23
48
|
};
|
|
24
49
|
}
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
...(
|
|
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
|
|
9
|
-
* caller, the publish fence that reports a late answer as
|
|
10
|
-
* the denial that drops a published snapshot unless it
|
|
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
|
-
|
|
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
|