|
| 1 | +import type { Config } from '../config/schema'; |
| 2 | +import { createCheckoutSession, fetchWorkspacePlan, type CheckoutRequest } from '../client/billing'; |
| 3 | +import { canWaitForBrowser, scopedSigint } from '../commands/helpers'; |
| 4 | +import { isApiError } from '../errors/api'; |
| 5 | +import { CLIError } from '../errors/base'; |
| 6 | +import { Spinner } from '../output/progress'; |
| 7 | +import { openBrowser } from '../utils/browser'; |
| 8 | +import { isInteractive } from '../utils/env'; |
| 9 | +import type { BillingCycle, CatalogPlan, WorkspacePlan } from './plans'; |
| 10 | +import { startReturnListener, type ReturnListener, type ReturnOutcome } from './return-listener'; |
| 11 | + |
| 12 | +// The one line every non-upgrade exit ends with, so nobody is left thinking |
| 13 | +// the door closed. |
| 14 | +export const UPGRADE_LATER = 'Upgrade any time: polylane subscription upgrade'; |
| 15 | + |
| 16 | +// upgraded: the workspace is on the new plan. |
| 17 | +// canceled: the customer backed out of Stripe (explicit, via the cancel URL). |
| 18 | +// timeout: nothing came back in time; treated as "not now". |
| 19 | +// pending: Stripe reported success but the plan has not flipped yet (webhook lag). |
| 20 | +// handoff: the URL was printed and nothing was waited for (json output or no TTY). |
| 21 | +export type CheckoutOutcome = 'upgraded' | 'canceled' | 'timeout' | 'pending' | 'handoff'; |
| 22 | + |
| 23 | +export interface CheckoutOptions { |
| 24 | + workspaceId: string; |
| 25 | + plan: CatalogPlan; |
| 26 | + cycle: BillingCycle; |
| 27 | + noBrowser: boolean; |
| 28 | + // The plan id before checkout; a different id on the plan endpoint is the |
| 29 | + // upgrade landing, whichever route the news arrives by. |
| 30 | + previousPlanId: string; |
| 31 | + timeoutMs?: number; |
| 32 | + pollIntervalMs?: number; |
| 33 | + // After the browser reports success, how long to wait for the plan flip. |
| 34 | + settleMs?: number; |
| 35 | +} |
| 36 | + |
| 37 | +export interface CheckoutDeps { |
| 38 | + startListener: () => Promise<ReturnListener | null>; |
| 39 | + openBrowser: (url: string) => void; |
| 40 | + fetchPlan: (config: Config, workspaceId: string) => Promise<WorkspacePlan>; |
| 41 | + createCheckout: (config: Config, body: CheckoutRequest) => Promise<{ url: string }>; |
| 42 | + sleep: (ms: number) => Promise<void>; |
| 43 | + // Whether this terminal can wait for the browser at all (a TTY, not json). |
| 44 | + canWait: (config: Config) => boolean; |
| 45 | + // stdout is data (the URL); injectable so tests never stub the process stream |
| 46 | + // the test runner itself reports through. |
| 47 | + writeOut: (line: string) => void; |
| 48 | +} |
| 49 | + |
| 50 | +const defaultDeps: CheckoutDeps = { |
| 51 | + startListener: () => startReturnListener(), |
| 52 | + openBrowser, |
| 53 | + fetchPlan: fetchWorkspacePlan, |
| 54 | + createCheckout: createCheckoutSession, |
| 55 | + sleep: (ms) => new Promise((r) => setTimeout(r, ms)), |
| 56 | + canWait: canWaitForBrowser, |
| 57 | + writeOut: (line) => { |
| 58 | + process.stdout.write(line); |
| 59 | + }, |
| 60 | +}; |
| 61 | + |
| 62 | +const DEFAULT_TIMEOUT_MS = 10 * 60_000; |
| 63 | +const DEFAULT_POLL_MS = 3_000; |
| 64 | +const DEFAULT_SETTLE_MS = 90_000; |
| 65 | + |
| 66 | +export const TERMS_LINE = 'Paying accepts the terms: https://polylane.com/terms'; |
| 67 | + |
| 68 | +// Only an https URL is ever handed to the OS opener. The API is trusted, but |
| 69 | +// --domain lets a user point the CLI anywhere, and `open file:///…` or a |
| 70 | +// custom scheme must never be one bad response away. |
| 71 | +export function isOpenableUrl(url: string): boolean { |
| 72 | + try { |
| 73 | + return new URL(url).protocol === 'https:'; |
| 74 | + } catch { |
| 75 | + return false; |
| 76 | + } |
| 77 | +} |
| 78 | + |
| 79 | +function printCheckoutUrl(config: Config, url: string, noBrowser: boolean, deps: CheckoutDeps): void { |
| 80 | + const shouldOpen = !noBrowser && isInteractive(config.nonInteractive) && isOpenableUrl(url); |
| 81 | + if (!config.quiet) { |
| 82 | + process.stderr.write(`${TERMS_LINE}\n`); |
| 83 | + process.stderr.write( |
| 84 | + shouldOpen ? "Opening checkout in your browser…\nIf it doesn't open, use this URL:\n" : 'Open this URL to upgrade:\n' |
| 85 | + ); |
| 86 | + } |
| 87 | + deps.writeOut(url + '\n'); |
| 88 | + if (shouldOpen) deps.openBrowser(url); |
| 89 | +} |
| 90 | + |
| 91 | +// Opens Stripe Checkout for `plan` and waits for the outcome. Two signals |
| 92 | +// race: the browser returning to the local listener (immediate, and the only |
| 93 | +// way to know about a cancel) and the plan endpoint flipping (the webhook |
| 94 | +// landed; also the fallback when no listener could bind or the API ignored |
| 95 | +// the return URLs). Ctrl+C stops waiting and counts as "not now". |
| 96 | +export async function runCheckout(config: Config, opts: CheckoutOptions, deps: CheckoutDeps = defaultDeps): Promise<CheckoutOutcome> { |
| 97 | + const waits = deps.canWait(config); |
| 98 | + const listener = waits ? await deps.startListener() : null; |
| 99 | + // Registered before the checkout request so a return that lands during it |
| 100 | + // is already visible when the wait starts. |
| 101 | + let returned: ReturnOutcome | null = null; |
| 102 | + void listener?.outcome.then((o) => { |
| 103 | + returned = o; |
| 104 | + }); |
| 105 | + const body: CheckoutRequest = { |
| 106 | + workspaceId: opts.workspaceId, |
| 107 | + plan: opts.plan.id, |
| 108 | + billingCycle: opts.cycle, |
| 109 | + ...(listener ? { successUrl: listener.successUrl, cancelUrl: listener.cancelUrl } : {}), |
| 110 | + }; |
| 111 | + let url: string; |
| 112 | + try { |
| 113 | + ({ url } = await deps.createCheckout(config, body)); |
| 114 | + } catch (err) { |
| 115 | + listener?.close(); |
| 116 | + if (isApiError(err)) { |
| 117 | + throw new CLIError(`Couldn't start checkout: ${err.message}`, err.exitCode, [err.hint, UPGRADE_LATER].filter(Boolean).join('\n')); |
| 118 | + } |
| 119 | + throw err; |
| 120 | + } |
| 121 | + if (config.output === 'json') { |
| 122 | + listener?.close(); |
| 123 | + deps.writeOut(JSON.stringify({ url }, null, 2) + '\n'); |
| 124 | + return 'handoff'; |
| 125 | + } |
| 126 | + printCheckoutUrl(config, url, opts.noBrowser, deps); |
| 127 | + if (!waits) return 'handoff'; |
| 128 | + |
| 129 | + const planFlipped = async (): Promise<boolean> => { |
| 130 | + try { |
| 131 | + const plan = await deps.fetchPlan(config, opts.workspaceId); |
| 132 | + return plan.plan.id !== opts.previousPlanId; |
| 133 | + } catch { |
| 134 | + return false; |
| 135 | + } |
| 136 | + }; |
| 137 | + |
| 138 | + const spinner = new Spinner('Waiting for checkout… (Ctrl+C to stop waiting)'); |
| 139 | + let interrupted = false; |
| 140 | + const restoreSigint = scopedSigint(() => { |
| 141 | + interrupted = true; |
| 142 | + }); |
| 143 | + if (!config.quiet) process.stderr.write('\nFinish in the browser, then come back to this terminal.\n'); |
| 144 | + spinner.start(); |
| 145 | + try { |
| 146 | + const poll = opts.pollIntervalMs ?? DEFAULT_POLL_MS; |
| 147 | + const deadline = Date.now() + (opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); |
| 148 | + // Check before the first sleep so an instant cancel is not held for a poll interval. |
| 149 | + for (let first = true; Date.now() < deadline && !interrupted; first = false) { |
| 150 | + if (!first) await deps.sleep(poll); |
| 151 | + if (interrupted) break; |
| 152 | + if (returned === 'cancel') return 'canceled'; |
| 153 | + if (await planFlipped()) return 'upgraded'; |
| 154 | + if (returned === 'success') { |
| 155 | + spinner.update('Payment received, updating your plan…'); |
| 156 | + const settleBy = Date.now() + (opts.settleMs ?? DEFAULT_SETTLE_MS); |
| 157 | + while (Date.now() < settleBy && !interrupted) { |
| 158 | + await deps.sleep(poll); |
| 159 | + if (await planFlipped()) return 'upgraded'; |
| 160 | + } |
| 161 | + return 'pending'; |
| 162 | + } |
| 163 | + } |
| 164 | + return 'timeout'; |
| 165 | + } finally { |
| 166 | + spinner.stop(interrupted ? 'Stopped waiting. Finish in the browser any time; your plan updates on its own.' : undefined); |
| 167 | + restoreSigint(); |
| 168 | + listener?.close(); |
| 169 | + } |
| 170 | +} |
0 commit comments