@comfyorg/account-core 1.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/core/billing/balanceWatch.d.ts +35 -0
- package/dist/core/billing/balanceWatch.js +83 -0
- package/dist/core/billing/billingContracts.d.ts +123 -0
- package/dist/core/billing/billingContracts.js +11 -0
- package/dist/core/billing/billingErrorBody.d.ts +8 -0
- package/dist/core/billing/billingErrorBody.js +11 -0
- package/dist/core/billing/billingScope.d.ts +39 -0
- package/dist/core/billing/billingScope.js +45 -0
- package/dist/core/billing/capabilities.d.ts +70 -0
- package/dist/core/billing/capabilities.js +137 -0
- package/dist/core/billing/capabilityDenials.d.ts +26 -0
- package/dist/core/billing/capabilityDenials.js +43 -0
- package/dist/core/billing/challengeDriver.d.ts +22 -0
- package/dist/core/billing/challengeDriver.js +38 -0
- package/dist/core/billing/credentialedTransport.d.ts +35 -0
- package/dist/core/billing/credentialedTransport.js +53 -0
- package/dist/core/billing/credits.d.ts +43 -0
- package/dist/core/billing/credits.js +29 -0
- package/dist/core/billing/httpStatus.d.ts +7 -0
- package/dist/core/billing/httpStatus.js +25 -0
- package/dist/core/billing/index.d.ts +52 -0
- package/dist/core/billing/index.js +23 -0
- package/dist/core/billing/operationLifecycle.d.ts +77 -0
- package/dist/core/billing/operationLifecycle.js +547 -0
- package/dist/core/billing/operationPointer.d.ts +48 -0
- package/dist/core/billing/operationPointer.js +89 -0
- package/dist/core/billing/operationPolicy.d.ts +26 -0
- package/dist/core/billing/operationPolicy.js +51 -0
- package/dist/core/billing/operationState.d.ts +128 -0
- package/dist/core/billing/operationState.js +187 -0
- package/dist/core/billing/paymentCopy.d.ts +18 -0
- package/dist/core/billing/paymentCopy.js +47 -0
- package/dist/core/billing/paymentMethods.d.ts +40 -0
- package/dist/core/billing/paymentMethods.js +37 -0
- package/dist/core/billing/paymentProjection.d.ts +34 -0
- package/dist/core/billing/paymentProjection.js +56 -0
- package/dist/core/billing/plans.d.ts +44 -0
- package/dist/core/billing/plans.js +29 -0
- package/dist/core/billing/presentation.d.ts +20 -0
- package/dist/core/billing/presentation.js +10 -0
- package/dist/core/billing/scopedReader.d.ts +61 -0
- package/dist/core/billing/scopedReader.js +108 -0
- package/dist/core/billing/sharedRead.d.ts +33 -0
- package/dist/core/billing/sharedRead.js +59 -0
- package/dist/core/billing/status.d.ts +27 -0
- package/dist/core/billing/status.js +21 -0
- package/dist/core/billing/subscriptionCommands.d.ts +367 -0
- package/dist/core/billing/subscriptionCommands.js +263 -0
- package/dist/core/billing/topup.d.ts +113 -0
- package/dist/core/billing/topup.js +209 -0
- package/dist/core/billing/transport.d.ts +14 -0
- package/dist/core/billing/transport.js +106 -0
- package/dist/core/billing/transportExchange.d.ts +24 -0
- package/dist/core/billing/transportExchange.js +79 -0
- package/dist/core/boundedOperation.d.ts +16 -0
- package/dist/core/boundedOperation.js +12 -0
- package/dist/core/credentialCache.d.ts +41 -0
- package/dist/core/credentialCache.js +97 -0
- package/dist/core/customerRecovery.d.ts +39 -0
- package/dist/core/customerRecovery.js +79 -0
- package/dist/core/exchange.d.ts +50 -0
- package/dist/core/exchange.js +137 -0
- package/dist/core/identity.d.ts +21 -0
- package/dist/core/identity.js +16 -0
- package/dist/core/mintCoordinator.d.ts +18 -0
- package/dist/core/mintCoordinator.js +31 -0
- package/dist/core/refreshScheduler.d.ts +46 -0
- package/dist/core/refreshScheduler.js +262 -0
- package/dist/core/session.d.ts +146 -0
- package/dist/core/session.js +312 -0
- package/dist/core/sessionContracts.d.ts +135 -0
- package/dist/core/sessionContracts.js +9 -0
- package/dist/core/sessionState.d.ts +118 -0
- package/dist/core/sessionState.js +235 -0
- package/dist/firebase/index.d.ts +53 -0
- package/dist/firebase/index.js +74 -0
- package/dist/firebaseAuthError.d.ts +52 -0
- package/dist/firebaseAuthError.js +88 -0
- package/dist/loadExternalScript.d.ts +7 -0
- package/dist/loadExternalScript.js +88 -0
- package/dist/provisioning.d.ts +53 -0
- package/dist/provisioning.js +76 -0
- package/dist/redirect.d.ts +13 -0
- package/dist/redirect.js +38 -0
- package/dist/signInSchemas.d.ts +88 -0
- package/dist/signInSchemas.js +71 -0
- package/dist/telemetry.d.ts +39 -0
- package/dist/telemetry.js +26 -0
- package/dist/testing.d.ts +7 -0
- package/dist/testing.js +3 -0
- package/dist/turnstile.d.ts +14 -0
- package/dist/turnstile.js +17 -0
- package/dist/turnstileScript.d.ts +18 -0
- package/dist/turnstileScript.js +6 -0
- package/dist/web/crossTabRefresh.d.ts +18 -0
- package/dist/web/crossTabRefresh.js +121 -0
- package/dist/webviewDetection.d.ts +5 -0
- package/dist/webviewDetection.js +62 -0
- package/package.json +114 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { PendingBillingOperation } from './operationState.js';
|
|
2
|
+
export declare const OPERATION_POLL_TIMING: {
|
|
3
|
+
readonly initialMs: 1000;
|
|
4
|
+
readonly maxMs: 8000;
|
|
5
|
+
readonly multiplier: 1.5;
|
|
6
|
+
/** An operation parked on the customer is checked on a slow, flat cadence. */
|
|
7
|
+
readonly parkedMs: 30000;
|
|
8
|
+
};
|
|
9
|
+
export declare const OPERATION_POLL_BUDGET: {
|
|
10
|
+
readonly defaultMs: 120000;
|
|
11
|
+
/** A subscription may take minutes to discover that it needs the customer. */
|
|
12
|
+
readonly subscriptionDiscoveryMs: number;
|
|
13
|
+
/** Once the customer is involved, a bank challenge can idle for hours. */
|
|
14
|
+
readonly customerActionMs: number;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Waiting on the customer, not on the backend: a challenge to complete
|
|
18
|
+
* elsewhere, a hosted page to finish, a phase the server reports as blocked on
|
|
19
|
+
* them, or a declined attempt awaiting their retry. Once this tab's own
|
|
20
|
+
* challenge completes the state reads processing and nothing waits on the
|
|
21
|
+
* customer anymore.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isParkedOnCustomer(state: PendingBillingOperation): boolean;
|
|
24
|
+
export declare function nextPollDelayMs(state: PendingBillingOperation, previousDelayMs: number | undefined): number;
|
|
25
|
+
export declare function pollBudgetMs(state: PendingBillingOperation): number;
|
|
26
|
+
export declare function hasExhaustedPollBudget(state: PendingBillingOperation, now: number): boolean;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cadence and budget for observing one operation, taken from the cloud
|
|
3
|
+
* app's `billingOperationStore` rather than invented, so a consumer moving
|
|
4
|
+
* onto the SDK sees the same request rate and the same give-up points.
|
|
5
|
+
*/
|
|
6
|
+
import { isBlockedOnCustomerPhase } from './operationState.js';
|
|
7
|
+
export const OPERATION_POLL_TIMING = {
|
|
8
|
+
initialMs: 1_000,
|
|
9
|
+
maxMs: 8_000,
|
|
10
|
+
multiplier: 1.5,
|
|
11
|
+
/** An operation parked on the customer is checked on a slow, flat cadence. */
|
|
12
|
+
parkedMs: 30_000
|
|
13
|
+
};
|
|
14
|
+
export const OPERATION_POLL_BUDGET = {
|
|
15
|
+
defaultMs: 120_000,
|
|
16
|
+
/** A subscription may take minutes to discover that it needs the customer. */
|
|
17
|
+
subscriptionDiscoveryMs: 5 * 60_000,
|
|
18
|
+
/** Once the customer is involved, a bank challenge can idle for hours. */
|
|
19
|
+
customerActionMs: 23 * 60 * 60_000
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Waiting on the customer, not on the backend: a challenge to complete
|
|
23
|
+
* elsewhere, a hosted page to finish, a phase the server reports as blocked on
|
|
24
|
+
* them, or a declined attempt awaiting their retry. Once this tab's own
|
|
25
|
+
* challenge completes the state reads processing and nothing waits on the
|
|
26
|
+
* customer anymore.
|
|
27
|
+
*/
|
|
28
|
+
export function isParkedOnCustomer(state) {
|
|
29
|
+
return (state.authenticationState === 'requires_action' ||
|
|
30
|
+
state.actionUrl !== undefined ||
|
|
31
|
+
isBlockedOnCustomerPhase(state.serverPhase) ||
|
|
32
|
+
(state.authenticationState === 'failed_retryable' &&
|
|
33
|
+
state.customerActionSeen));
|
|
34
|
+
}
|
|
35
|
+
export function nextPollDelayMs(state, previousDelayMs) {
|
|
36
|
+
if (isParkedOnCustomer(state))
|
|
37
|
+
return OPERATION_POLL_TIMING.parkedMs;
|
|
38
|
+
return Math.min((previousDelayMs ?? OPERATION_POLL_TIMING.initialMs) *
|
|
39
|
+
OPERATION_POLL_TIMING.multiplier, OPERATION_POLL_TIMING.maxMs);
|
|
40
|
+
}
|
|
41
|
+
export function pollBudgetMs(state) {
|
|
42
|
+
if (state.kind !== 'cancel' && state.customerActionSeen) {
|
|
43
|
+
return OPERATION_POLL_BUDGET.customerActionMs;
|
|
44
|
+
}
|
|
45
|
+
return state.kind === 'subscription'
|
|
46
|
+
? OPERATION_POLL_BUDGET.subscriptionDiscoveryMs
|
|
47
|
+
: OPERATION_POLL_BUDGET.defaultMs;
|
|
48
|
+
}
|
|
49
|
+
export function hasExhaustedPollBudget(state, now) {
|
|
50
|
+
return now - state.observedAt > pollBudgetMs(state);
|
|
51
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
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';
|
|
16
|
+
import type { BillingScope } from './billingScope.js';
|
|
17
|
+
export type BillingOpStatus = z.infer<typeof zBillingOpStatusResponse>;
|
|
18
|
+
export type BillingOperationKind = 'subscription' | 'topup' | 'cancel';
|
|
19
|
+
/**
|
|
20
|
+
* Where the customer completes the operation: the challenge this tab drives
|
|
21
|
+
* through the host's payment-provider adapter, or the page behind the
|
|
22
|
+
* server's `action_url`.
|
|
23
|
+
*/
|
|
24
|
+
export type BillingPresentation = 'embedded' | 'hosted';
|
|
25
|
+
/**
|
|
26
|
+
* Which origin serves a hosted presentation: the provider page behind the
|
|
27
|
+
* server's `action_url`, or the hosted billing app.
|
|
28
|
+
*/
|
|
29
|
+
export type HostedBillingDestination = 'stripe' | 'billing_web';
|
|
30
|
+
export type BillingDeclineReason = NonNullable<BillingOpStatus['decline_reason']>;
|
|
31
|
+
export type BillingRecoveryAction = NonNullable<BillingOpStatus['recovery_action']>;
|
|
32
|
+
export type BillingAuthenticationState = NonNullable<BillingOpStatus['authentication_state']>;
|
|
33
|
+
export type BillingOperationServerPhase = NonNullable<BillingOpStatus['phase']>;
|
|
34
|
+
/**
|
|
35
|
+
* The phases the contract defines as blocked on the customer. Neither advances
|
|
36
|
+
* on its own, so an operation reporting one waits on them even before it has a
|
|
37
|
+
* link to offer.
|
|
38
|
+
*/
|
|
39
|
+
export declare function isBlockedOnCustomerPhase(phase: BillingOperationServerPhase | null | undefined): boolean;
|
|
40
|
+
export type BillingPresentationState = {
|
|
41
|
+
readonly presentation: 'embedded';
|
|
42
|
+
readonly hostedDestination?: never;
|
|
43
|
+
} | {
|
|
44
|
+
readonly presentation: 'hosted';
|
|
45
|
+
readonly hostedDestination: HostedBillingDestination;
|
|
46
|
+
};
|
|
47
|
+
export type BillingOperationIdentity = BillingPresentationState & {
|
|
48
|
+
readonly id: string;
|
|
49
|
+
readonly kind: BillingOperationKind;
|
|
50
|
+
readonly scope: BillingScope;
|
|
51
|
+
/** When this tab began observing the operation; the poll budget counts from here. */
|
|
52
|
+
readonly observedAt: number;
|
|
53
|
+
/** When the attempt began, before the command was issued; telemetry durations count from here. */
|
|
54
|
+
readonly attemptStartedAt: number;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The in-page challenge for an embedded presentation. `completed` and
|
|
58
|
+
* `failed` are this tab's verdicts and outrank a server echo of the same
|
|
59
|
+
* challenge, so the presentation neither flaps back to "verify" after a
|
|
60
|
+
* failure nor reopens the pay step mid-payment after a success.
|
|
61
|
+
*/
|
|
62
|
+
export interface EmbeddedChallenge {
|
|
63
|
+
readonly clientSecret: string;
|
|
64
|
+
readonly status: 'required' | 'in_progress' | 'completed' | 'failed';
|
|
65
|
+
}
|
|
66
|
+
export type PendingBillingOperation = BillingOperationIdentity & {
|
|
67
|
+
readonly phase: 'pending';
|
|
68
|
+
/** The hosted continuation the server currently offers; https only. */
|
|
69
|
+
readonly actionUrl?: string;
|
|
70
|
+
readonly challenge?: EmbeddedChallenge;
|
|
71
|
+
readonly authenticationState?: BillingAuthenticationState;
|
|
72
|
+
readonly serverPhase?: BillingOperationServerPhase;
|
|
73
|
+
/** Set while the customer's last attempt was declined and they may try again. */
|
|
74
|
+
readonly declineReason?: BillingDeclineReason;
|
|
75
|
+
readonly recoveryAction?: BillingRecoveryAction;
|
|
76
|
+
/** True once the operation has ever waited on the customer; widens the poll budget. */
|
|
77
|
+
readonly customerActionSeen: boolean;
|
|
78
|
+
};
|
|
79
|
+
export type FailedBillingOperation = BillingOperationIdentity & {
|
|
80
|
+
readonly phase: 'failed';
|
|
81
|
+
readonly declineReason: BillingDeclineReason;
|
|
82
|
+
readonly recoveryAction?: BillingRecoveryAction;
|
|
83
|
+
readonly retryable: boolean;
|
|
84
|
+
};
|
|
85
|
+
export type BillingOperationState = PendingBillingOperation | (BillingOperationIdentity & {
|
|
86
|
+
readonly phase: 'succeeded';
|
|
87
|
+
}) | FailedBillingOperation
|
|
88
|
+
/** This tab's poll budget ran out; the server may still settle the operation. */
|
|
89
|
+
| (BillingOperationIdentity & {
|
|
90
|
+
readonly phase: 'timed_out';
|
|
91
|
+
})
|
|
92
|
+
/** The server cannot settle it without a human; surface the id to support. */
|
|
93
|
+
| (BillingOperationIdentity & {
|
|
94
|
+
readonly phase: 'reconciliation_needed';
|
|
95
|
+
})
|
|
96
|
+
/** The session or workspace changed underneath it; nothing here may be attributed to the new scope. */
|
|
97
|
+
| (BillingOperationIdentity & {
|
|
98
|
+
readonly phase: 'superseded';
|
|
99
|
+
});
|
|
100
|
+
export type BillingOperationPhase = BillingOperationState['phase'];
|
|
101
|
+
export type BillingOperationEvent = {
|
|
102
|
+
readonly type: 'status_polled';
|
|
103
|
+
readonly status: BillingOpStatus;
|
|
104
|
+
} | {
|
|
105
|
+
readonly type: 'timed_out';
|
|
106
|
+
} | {
|
|
107
|
+
readonly type: 'superseded';
|
|
108
|
+
}
|
|
109
|
+
/** The operation could not be found under this scope. */
|
|
110
|
+
| {
|
|
111
|
+
readonly type: 'lost';
|
|
112
|
+
} | {
|
|
113
|
+
readonly type: 'presentation_switched';
|
|
114
|
+
readonly presentation: 'hosted';
|
|
115
|
+
readonly hostedDestination: HostedBillingDestination;
|
|
116
|
+
} | {
|
|
117
|
+
readonly type: 'presentation_switched';
|
|
118
|
+
readonly presentation: 'embedded';
|
|
119
|
+
} | {
|
|
120
|
+
readonly type: 'challenge_started';
|
|
121
|
+
} | {
|
|
122
|
+
readonly type: 'challenge_settled';
|
|
123
|
+
readonly outcome: 'completed' | 'failed';
|
|
124
|
+
};
|
|
125
|
+
export declare function isTerminal(state: BillingOperationState): state is Exclude<BillingOperationState, PendingBillingOperation>;
|
|
126
|
+
/** A continuation link the SDK will hand to a host: absolute and https, nothing else. */
|
|
127
|
+
export declare function validateActionUrl(value: string | undefined): string | undefined;
|
|
128
|
+
export declare function reduceBillingOperation(state: BillingOperationState, event: BillingOperationEvent): BillingOperationState;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The phases the contract defines as blocked on the customer. Neither advances
|
|
3
|
+
* on its own, so an operation reporting one waits on them even before it has a
|
|
4
|
+
* link to offer.
|
|
5
|
+
*/
|
|
6
|
+
export function isBlockedOnCustomerPhase(phase) {
|
|
7
|
+
return (phase === 'awaiting_payment_method' || phase === 'awaiting_invoice_payment');
|
|
8
|
+
}
|
|
9
|
+
export function isTerminal(state) {
|
|
10
|
+
return state.phase !== 'pending';
|
|
11
|
+
}
|
|
12
|
+
/** A continuation link the SDK will hand to a host: absolute and https, nothing else. */
|
|
13
|
+
export function validateActionUrl(value) {
|
|
14
|
+
if (value === undefined)
|
|
15
|
+
return undefined;
|
|
16
|
+
try {
|
|
17
|
+
return new URL(value).protocol === 'https:' ? value : undefined;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function presentationOf(state) {
|
|
24
|
+
return state.presentation === 'hosted'
|
|
25
|
+
? { presentation: 'hosted', hostedDestination: state.hostedDestination }
|
|
26
|
+
: { presentation: 'embedded' };
|
|
27
|
+
}
|
|
28
|
+
function identityOf(state) {
|
|
29
|
+
return {
|
|
30
|
+
id: state.id,
|
|
31
|
+
kind: state.kind,
|
|
32
|
+
scope: state.scope,
|
|
33
|
+
observedAt: state.observedAt,
|
|
34
|
+
attemptStartedAt: state.attemptStartedAt,
|
|
35
|
+
...presentationOf(state)
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function withPhase(state, phase) {
|
|
39
|
+
return { ...identityOf(state), phase };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The server's `requires_action` is an echo of a challenge this tab already
|
|
43
|
+
* settled when it names the same client secret (or none). The tab's verdict
|
|
44
|
+
* stands until the intent actually moves.
|
|
45
|
+
*/
|
|
46
|
+
function echoesSettledChallenge(state, status) {
|
|
47
|
+
const challenge = state.challenge;
|
|
48
|
+
if (challenge === undefined)
|
|
49
|
+
return false;
|
|
50
|
+
if (status.authentication_state !== 'requires_action')
|
|
51
|
+
return false;
|
|
52
|
+
if (challenge.status !== 'completed' && challenge.status !== 'failed') {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
return (status.payment_intent_client_secret === undefined ||
|
|
56
|
+
status.payment_intent_client_secret === challenge.clientSecret);
|
|
57
|
+
}
|
|
58
|
+
function nextChallenge(state, status) {
|
|
59
|
+
if (state.presentation !== 'embedded')
|
|
60
|
+
return state.challenge;
|
|
61
|
+
const secret = status.payment_intent_client_secret;
|
|
62
|
+
if (secret === undefined)
|
|
63
|
+
return state.challenge;
|
|
64
|
+
if (state.challenge?.clientSecret === secret)
|
|
65
|
+
return state.challenge;
|
|
66
|
+
if (status.authentication_state !== 'requires_action')
|
|
67
|
+
return state.challenge;
|
|
68
|
+
return { clientSecret: secret, status: 'required' };
|
|
69
|
+
}
|
|
70
|
+
function terminalFromStatus(state, status) {
|
|
71
|
+
if (status.status === 'succeeded')
|
|
72
|
+
return withPhase(state, 'succeeded');
|
|
73
|
+
if (status.status === 'failed') {
|
|
74
|
+
return {
|
|
75
|
+
...identityOf(state),
|
|
76
|
+
phase: 'failed',
|
|
77
|
+
declineReason: status.decline_reason ?? 'generic',
|
|
78
|
+
...(status.recovery_action === undefined
|
|
79
|
+
? {}
|
|
80
|
+
: { recoveryAction: status.recovery_action }),
|
|
81
|
+
retryable: status.retryable === true
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (status.status === 'reconciliation_needed' ||
|
|
85
|
+
status.authentication_state === 'reconciliation_needed') {
|
|
86
|
+
return withPhase(state, 'reconciliation_needed');
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* A link echoed while this tab's completed challenge is still processing
|
|
92
|
+
* points at that same challenge; surfacing it would ask the customer to
|
|
93
|
+
* redo a step they just finished.
|
|
94
|
+
*/
|
|
95
|
+
function nextActionUrl(state, status, authenticationState) {
|
|
96
|
+
return state.challenge?.status === 'completed' &&
|
|
97
|
+
authenticationState !== 'requires_action'
|
|
98
|
+
? state.actionUrl
|
|
99
|
+
: validateActionUrl(status.action_url);
|
|
100
|
+
}
|
|
101
|
+
function reducePending(state, status) {
|
|
102
|
+
if (status.id !== state.id)
|
|
103
|
+
return state;
|
|
104
|
+
const terminal = terminalFromStatus(state, status);
|
|
105
|
+
if (terminal !== undefined)
|
|
106
|
+
return terminal;
|
|
107
|
+
const authenticationState = echoesSettledChallenge(state, status)
|
|
108
|
+
? state.authenticationState
|
|
109
|
+
: status.authentication_state;
|
|
110
|
+
const actionUrl = nextActionUrl(state, status, authenticationState);
|
|
111
|
+
const declineReason = authenticationState === 'failed_retryable'
|
|
112
|
+
? (status.decline_reason ?? state.declineReason)
|
|
113
|
+
: undefined;
|
|
114
|
+
return {
|
|
115
|
+
...state,
|
|
116
|
+
challenge: nextChallenge(state, status),
|
|
117
|
+
authenticationState,
|
|
118
|
+
actionUrl,
|
|
119
|
+
serverPhase: status.phase,
|
|
120
|
+
declineReason,
|
|
121
|
+
recoveryAction: status.recovery_action,
|
|
122
|
+
customerActionSeen: state.customerActionSeen ||
|
|
123
|
+
actionUrl !== undefined ||
|
|
124
|
+
isBlockedOnCustomerPhase(status.phase) ||
|
|
125
|
+
status.authentication_state === 'requires_action'
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function settleChallenge(state, outcome) {
|
|
129
|
+
if (state.challenge === undefined)
|
|
130
|
+
return state;
|
|
131
|
+
const challenge = { ...state.challenge, status: outcome };
|
|
132
|
+
if (outcome === 'failed') {
|
|
133
|
+
return {
|
|
134
|
+
...state,
|
|
135
|
+
challenge,
|
|
136
|
+
authenticationState: 'failed_retryable',
|
|
137
|
+
customerActionSeen: true
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
...state,
|
|
142
|
+
challenge,
|
|
143
|
+
authenticationState: 'processing',
|
|
144
|
+
actionUrl: undefined,
|
|
145
|
+
declineReason: undefined
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export function reduceBillingOperation(state, event) {
|
|
149
|
+
if (state.phase !== 'pending')
|
|
150
|
+
return state;
|
|
151
|
+
switch (event.type) {
|
|
152
|
+
case 'status_polled':
|
|
153
|
+
return reducePending(state, event.status);
|
|
154
|
+
case 'timed_out':
|
|
155
|
+
return withPhase(state, 'timed_out');
|
|
156
|
+
case 'superseded':
|
|
157
|
+
return withPhase(state, 'superseded');
|
|
158
|
+
case 'lost':
|
|
159
|
+
return withPhase(state, 'reconciliation_needed');
|
|
160
|
+
case 'presentation_switched': {
|
|
161
|
+
if (event.presentation === state.presentation)
|
|
162
|
+
return state;
|
|
163
|
+
// The challenge record survives a hosted switch so a rollback keeps the
|
|
164
|
+
// client secret; a failed challenge becomes required again on return.
|
|
165
|
+
const { presentation, hostedDestination, ...rest } = state;
|
|
166
|
+
return event.presentation === 'hosted'
|
|
167
|
+
? {
|
|
168
|
+
...rest,
|
|
169
|
+
presentation: 'hosted',
|
|
170
|
+
hostedDestination: event.hostedDestination
|
|
171
|
+
}
|
|
172
|
+
: {
|
|
173
|
+
...rest,
|
|
174
|
+
presentation: 'embedded',
|
|
175
|
+
...(state.challenge?.status === 'failed'
|
|
176
|
+
? { challenge: { ...state.challenge, status: 'required' } }
|
|
177
|
+
: {})
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
case 'challenge_started':
|
|
181
|
+
return state.challenge?.status === 'required'
|
|
182
|
+
? { ...state, challenge: { ...state.challenge, status: 'in_progress' } }
|
|
183
|
+
: state;
|
|
184
|
+
case 'challenge_settled':
|
|
185
|
+
return settleChallenge(state, event.outcome);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The copy keys behind the payment projection, with a default English table
|
|
3
|
+
* a host may override — except the safety line, which only the projection's
|
|
4
|
+
* `noChargeConfirmed` may ever unlock.
|
|
5
|
+
*/
|
|
6
|
+
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
|
+
declare const SAFETY_KEY = "billing.safety.nothing_was_charged";
|
|
9
|
+
export declare const DEFAULT_PAYMENT_COPY: Readonly<Record<PaymentCopyKey, string>>;
|
|
10
|
+
export declare function createPaymentCopy(overrides?: Partial<Record<PaymentCopyKey, string>>): Readonly<Record<PaymentCopyKey, string>>;
|
|
11
|
+
export interface PaymentCopyKeys {
|
|
12
|
+
readonly header: PaymentCopyKey;
|
|
13
|
+
readonly body: PaymentCopyKey;
|
|
14
|
+
readonly reason?: PaymentCopyKey;
|
|
15
|
+
readonly safety?: typeof SAFETY_KEY;
|
|
16
|
+
}
|
|
17
|
+
export declare function paymentCopyKeys(projection: PaymentProjection): PaymentCopyKeys;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const SAFETY_KEY = 'billing.safety.nothing_was_charged';
|
|
2
|
+
export const DEFAULT_PAYMENT_COPY = {
|
|
3
|
+
'billing.step.select.header': 'Choose an option',
|
|
4
|
+
'billing.step.select.body': 'Select a plan or credit amount.',
|
|
5
|
+
'billing.step.preview.header': 'Review payment',
|
|
6
|
+
'billing.step.preview.body': 'Confirm your selection.',
|
|
7
|
+
'billing.step.verifying.header': 'Verify your payment',
|
|
8
|
+
'billing.step.verifying.body': 'Complete verification to continue.',
|
|
9
|
+
'billing.step.canceled.header': 'Payment canceled',
|
|
10
|
+
'billing.step.canceled.body': 'The payment was canceled.',
|
|
11
|
+
'billing.step.declined.header': 'Payment declined',
|
|
12
|
+
'billing.step.declined.body': 'Try another payment method.',
|
|
13
|
+
'billing.step.processing_error.header': 'Payment could not be processed',
|
|
14
|
+
'billing.step.processing_error.body': 'Please try again.',
|
|
15
|
+
'billing.step.payment_received_hold.header': 'Payment received',
|
|
16
|
+
'billing.step.payment_received_hold.body': 'We are finishing your account update.',
|
|
17
|
+
'billing.step.success.header': 'Payment complete',
|
|
18
|
+
'billing.step.success.body': 'Your account is updated.',
|
|
19
|
+
'billing.reason.generic': 'Something went wrong.',
|
|
20
|
+
'billing.reason.checkout_expired': 'This checkout expired. Start again when you are ready.',
|
|
21
|
+
'billing.reason.card_declined': 'Your bank declined the payment.',
|
|
22
|
+
'billing.reason.insufficient_funds': 'Your bank reported insufficient funds.',
|
|
23
|
+
'billing.reason.expired_card': 'This card has expired.',
|
|
24
|
+
'billing.reason.incorrect_cvc': 'The security code did not match.',
|
|
25
|
+
'billing.reason.authentication_required': 'Your bank requires authentication.',
|
|
26
|
+
'billing.reason.authentication_failed': 'Authentication with your bank did not complete.',
|
|
27
|
+
'billing.reason.processing_error': 'The payment could not be processed right now.',
|
|
28
|
+
'billing.action.retry': 'Try again',
|
|
29
|
+
'billing.action.continue_verification': 'Continue verification',
|
|
30
|
+
[SAFETY_KEY]: 'Nothing was charged.'
|
|
31
|
+
};
|
|
32
|
+
export function createPaymentCopy(overrides = {}) {
|
|
33
|
+
const { [SAFETY_KEY]: _protected, ...safe } = overrides;
|
|
34
|
+
return { ...DEFAULT_PAYMENT_COPY, ...safe };
|
|
35
|
+
}
|
|
36
|
+
export function paymentCopyKeys(projection) {
|
|
37
|
+
return {
|
|
38
|
+
header: `billing.step.${projection.step}.header`,
|
|
39
|
+
body: `billing.step.${projection.step}.body`,
|
|
40
|
+
...(projection.reasonKey === undefined
|
|
41
|
+
? {}
|
|
42
|
+
: { reason: `billing.reason.${projection.reasonKey}` }),
|
|
43
|
+
...(projection.step === 'canceled' && projection.noChargeConfirmed
|
|
44
|
+
? { safety: SAFETY_KEY }
|
|
45
|
+
: {})
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { zSavedPaymentMethod } from '@comfyorg/ingest-types/zod';
|
|
2
|
+
import type { z } from 'zod';
|
|
3
|
+
import type { BillingResult, BillingTransport } from './billingContracts.js';
|
|
4
|
+
import type { BillingScope, BillingScopeSource } from './billingScope.js';
|
|
5
|
+
export declare const PAYMENT_METHODS_ROUTE = "/billing/payment-methods";
|
|
6
|
+
export type SavedPaymentMethod = z.infer<typeof zSavedPaymentMethod>;
|
|
7
|
+
export type PaymentMethodsScope = BillingScope;
|
|
8
|
+
export interface PaymentMethodsSnapshot {
|
|
9
|
+
readonly scope: PaymentMethodsScope;
|
|
10
|
+
readonly methods: readonly SavedPaymentMethod[];
|
|
11
|
+
/** ms since epoch at which this read settled. */
|
|
12
|
+
readonly readAt: number;
|
|
13
|
+
}
|
|
14
|
+
export interface PaymentMethodsReadOptions {
|
|
15
|
+
readonly signal?: AbortSignal;
|
|
16
|
+
/** Bounds the request, when this caller is the one that issues it. */
|
|
17
|
+
readonly timeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface PaymentMethodsReader {
|
|
20
|
+
/**
|
|
21
|
+
* Always requests. Concurrent callers for one scope share a single request.
|
|
22
|
+
*/
|
|
23
|
+
read: (options?: PaymentMethodsReadOptions) => Promise<BillingResult<PaymentMethodsSnapshot>>;
|
|
24
|
+
/** The last list published for the current scope. */
|
|
25
|
+
getSnapshot: () => PaymentMethodsSnapshot | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Drops the published list without a scope change. A payment-portal round
|
|
28
|
+
* trip adds or removes a card outside this reader, so the host that sent the
|
|
29
|
+
* user there is the only thing that knows the list it holds is now wrong.
|
|
30
|
+
*/
|
|
31
|
+
invalidate: () => void;
|
|
32
|
+
dispose: () => void;
|
|
33
|
+
}
|
|
34
|
+
export interface PaymentMethodsReaderOptions {
|
|
35
|
+
readonly transport: BillingTransport;
|
|
36
|
+
/** Where the core learns which user, workspace, and role it runs as. */
|
|
37
|
+
readonly scopeSource: BillingScopeSource;
|
|
38
|
+
readonly now?: () => number;
|
|
39
|
+
}
|
|
40
|
+
export declare function createPaymentMethodsReader(options: PaymentMethodsReaderOptions): PaymentMethodsReader;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The saved payment methods read: the cards a workspace can charge, cached per
|
|
3
|
+
* scope.
|
|
4
|
+
*
|
|
5
|
+
* Card details never reach this core beyond what the server already publishes —
|
|
6
|
+
* brand, last four digits, and which one is the default — so nothing here has
|
|
7
|
+
* to be redacted before a host renders it.
|
|
8
|
+
*/
|
|
9
|
+
import { zListSavedPaymentMethodsResponse } from '@comfyorg/ingest-types/zod';
|
|
10
|
+
import { createScopedReader } from './scopedReader.js';
|
|
11
|
+
export const PAYMENT_METHODS_ROUTE = '/billing/payment-methods';
|
|
12
|
+
export function createPaymentMethodsReader(options) {
|
|
13
|
+
const { transport, scopeSource, now = Date.now } = options;
|
|
14
|
+
const reader = createScopedReader({
|
|
15
|
+
transport,
|
|
16
|
+
scopeSource,
|
|
17
|
+
route: PAYMENT_METHODS_ROUTE,
|
|
18
|
+
parse: (body) => zListSavedPaymentMethodsResponse.safeParse(body),
|
|
19
|
+
timeoutMs: (readOptions) => readOptions?.timeoutMs,
|
|
20
|
+
project: ({ data }, scope) => ({
|
|
21
|
+
status: 'ok',
|
|
22
|
+
value: { scope, methods: data, readAt: now() }
|
|
23
|
+
}),
|
|
24
|
+
// A list resolved before the change the host is reporting may be served to
|
|
25
|
+
// the callers already waiting on it, but must not be published.
|
|
26
|
+
publish: (value, fenced) => (fenced ? undefined : value)
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
read: reader.read,
|
|
30
|
+
getSnapshot: reader.getSnapshot,
|
|
31
|
+
invalidate: () => {
|
|
32
|
+
reader.setSnapshot(undefined);
|
|
33
|
+
reader.fenceInFlight({ detach: true });
|
|
34
|
+
},
|
|
35
|
+
dispose: reader.dispose
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The eight public payment states as a pure projection of one lifecycle
|
|
3
|
+
* operation. A host renders the projection and reports where it is when no
|
|
4
|
+
* operation exists; it never advances the machine itself.
|
|
5
|
+
*
|
|
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.
|
|
8
|
+
*/
|
|
9
|
+
import type { BillingDeclineReason, BillingOperationState, BillingRecoveryAction } from './operationState.js';
|
|
10
|
+
export type PaymentStep = 'select' | 'preview' | 'verifying' | 'canceled' | 'declined' | 'processing_error'
|
|
11
|
+
/** Reserved: the contract cannot yet tell "paid, entitlement pending" from "needs a human". */
|
|
12
|
+
| 'payment_received_hold' | 'success';
|
|
13
|
+
/** Where the host is when nothing is being observed, or the customer backed out. */
|
|
14
|
+
export type HostPaymentStep = 'select' | 'preview' | 'canceled';
|
|
15
|
+
/**
|
|
16
|
+
* The generated `decline_reason` values plus the two the SDK adds. Every key
|
|
17
|
+
* is a coded reason; raw server or provider text never becomes one.
|
|
18
|
+
* `checkout_expired` is reserved: the generated operation status has no
|
|
19
|
+
* expired state to produce it from.
|
|
20
|
+
*/
|
|
21
|
+
export type PaymentReasonKey = BillingDeclineReason | 'checkout_expired' | 'generic';
|
|
22
|
+
export interface PaymentProjection {
|
|
23
|
+
readonly step: PaymentStep;
|
|
24
|
+
readonly reasonKey?: PaymentReasonKey;
|
|
25
|
+
readonly recoveryAction?: BillingRecoveryAction;
|
|
26
|
+
/** Present whenever an operation backs the projection; the id support can act on. */
|
|
27
|
+
readonly operationId?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Permits "nothing was charged" only once a terminal backend result rules
|
|
30
|
+
* out any later charge; the contract has no such field yet, so always false.
|
|
31
|
+
*/
|
|
32
|
+
readonly noChargeConfirmed: boolean;
|
|
33
|
+
}
|
|
34
|
+
export declare function projectPaymentStep(operation: BillingOperationState | undefined, hostStep: HostPaymentStep): PaymentProjection;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Reasons that describe a processing fault rather than the customer's card. */
|
|
2
|
+
const PROCESSING_REASONS = new Set([
|
|
3
|
+
'processing_error',
|
|
4
|
+
'generic'
|
|
5
|
+
]);
|
|
6
|
+
function stepForReason(reason) {
|
|
7
|
+
return PROCESSING_REASONS.has(reason) ? 'processing_error' : 'declined';
|
|
8
|
+
}
|
|
9
|
+
function projectPending(state, hostStep) {
|
|
10
|
+
const base = { operationId: state.id, noChargeConfirmed: false };
|
|
11
|
+
if (hostStep === 'canceled')
|
|
12
|
+
return { ...base, step: 'canceled' };
|
|
13
|
+
const reason = state.declineReason ??
|
|
14
|
+
(state.challenge?.status === 'failed' ? 'authentication_failed' : undefined);
|
|
15
|
+
if (reason !== undefined) {
|
|
16
|
+
return {
|
|
17
|
+
...base,
|
|
18
|
+
step: stepForReason(reason),
|
|
19
|
+
reasonKey: reason,
|
|
20
|
+
...(state.recoveryAction === undefined
|
|
21
|
+
? {}
|
|
22
|
+
: { recoveryAction: state.recoveryAction })
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const parked = state.challenge !== undefined || state.actionUrl !== undefined;
|
|
26
|
+
return { ...base, step: parked ? 'verifying' : 'preview' };
|
|
27
|
+
}
|
|
28
|
+
export function projectPaymentStep(operation, hostStep) {
|
|
29
|
+
if (operation === undefined) {
|
|
30
|
+
return { step: hostStep, noChargeConfirmed: false };
|
|
31
|
+
}
|
|
32
|
+
const base = { operationId: operation.id, noChargeConfirmed: false };
|
|
33
|
+
switch (operation.phase) {
|
|
34
|
+
case 'pending':
|
|
35
|
+
return projectPending(operation, hostStep);
|
|
36
|
+
case 'succeeded':
|
|
37
|
+
return { ...base, step: 'success' };
|
|
38
|
+
case 'failed':
|
|
39
|
+
return {
|
|
40
|
+
...base,
|
|
41
|
+
step: stepForReason(operation.declineReason),
|
|
42
|
+
reasonKey: operation.declineReason,
|
|
43
|
+
...(operation.recoveryAction === undefined
|
|
44
|
+
? {}
|
|
45
|
+
: { recoveryAction: operation.recoveryAction })
|
|
46
|
+
};
|
|
47
|
+
case 'reconciliation_needed':
|
|
48
|
+
return { ...base, step: 'processing_error', reasonKey: 'generic' };
|
|
49
|
+
case 'timed_out':
|
|
50
|
+
return hostStep === 'canceled'
|
|
51
|
+
? { ...base, step: 'canceled' }
|
|
52
|
+
: { ...base, step: 'processing_error', reasonKey: 'generic' };
|
|
53
|
+
case 'superseded':
|
|
54
|
+
return { ...base, step: hostStep === 'canceled' ? 'canceled' : 'select' };
|
|
55
|
+
}
|
|
56
|
+
}
|