From c8ba8f7fc67bfb9996aabde98a189b2e44e9cc92 Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Wed, 22 Apr 2026 11:34:39 -0500 Subject: [PATCH 1/9] fix: correctly show credits remaining from new API --- packages/cli/src/commands/billing/status.ts | 27 ++++--------------- .../src/client/common/config/environment.ts | 13 ++++++++- .../sdk/src/client/common/utils/billingapi.ts | 20 +++++++++++--- .../sdk/src/client/modules/billing/index.ts | 2 +- 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/packages/cli/src/commands/billing/status.ts b/packages/cli/src/commands/billing/status.ts index fba72130..2089dd04 100644 --- a/packages/cli/src/commands/billing/status.ts +++ b/packages/cli/src/commands/billing/status.ts @@ -88,28 +88,11 @@ export default class BillingStatus extends Command { } } - // Display invoice summary with credits - if (result.creditsApplied !== undefined && result.creditsApplied > 0) { - this.log(`\n${chalk.bold(" Invoice Summary:")}`); - const subtotal = result.upcomingInvoiceSubtotal ?? result.upcomingInvoiceTotal ?? 0; - this.log(` Subtotal: $${subtotal.toFixed(2)}`); - this.log(` Credits Applied: ${chalk.green(`-$${result.creditsApplied.toFixed(2)}`)}`); - this.log(` ${"─".repeat(21)}`); - this.log(` Total Due: $${(result.upcomingInvoiceTotal ?? 0).toFixed(2)}`); - - if (result.remainingCredits !== undefined) { - this.log( - `\n ${chalk.bold("Remaining Credits:")} ${chalk.cyan(`$${result.remainingCredits.toFixed(2)}`)}${formatExpiry(result.nextCreditExpiry)}`, - ); - } - } else if (result.upcomingInvoiceTotal !== undefined) { - this.log(`\n Upcoming Invoice: $${result.upcomingInvoiceTotal.toFixed(2)}`); - if (result.remainingCredits !== undefined && result.remainingCredits > 0) { - this.log( - ` ${chalk.bold("Available Credits:")} ${chalk.cyan(`$${result.remainingCredits.toFixed(2)}`)}${formatExpiry(result.nextCreditExpiry)}`, - ); - } - } + // Display remaining credits + const credits = result.remainingCredits ?? 0; + this.log( + ` Credits: ${chalk.cyan(`$${credits.toFixed(2)}`)}${formatExpiry(result.nextCreditExpiry)}`, + ); // Display cancellation information if (result.cancelAtPeriodEnd) { diff --git a/packages/sdk/src/client/common/config/environment.ts b/packages/sdk/src/client/common/config/environment.ts index db092f6f..7138392f 100644 --- a/packages/sdk/src/client/common/config/environment.ts +++ b/packages/sdk/src/client/common/config/environment.ts @@ -135,6 +135,12 @@ export function getEnvironmentConfig(environment: string, chainID?: bigint): Env ...env, chainID: BigInt(resolvedChainID), ...(apiUrlOverride ? { userApiServerURL: apiUrlOverride } : {}), + ...(process.env.ECLOUD_USER_API_URL && { + userApiServerURL: process.env.ECLOUD_USER_API_URL, + }), + ...(process.env.ECLOUD_RPC_URL && { + defaultRPCURL: process.env.ECLOUD_RPC_URL, + }), }; } @@ -153,7 +159,12 @@ export function getBillingEnvironmentConfig(build: "dev" | "prod"): { if (apiUrlOverride) { return { billingApiServerURL: apiUrlOverride }; } - return config; + return { + ...config, + ...(process.env.ECLOUD_BILLING_API_URL && { + billingApiServerURL: process.env.ECLOUD_BILLING_API_URL, + }), + }; } /** diff --git a/packages/sdk/src/client/common/utils/billingapi.ts b/packages/sdk/src/client/common/utils/billingapi.ts index 3dbbfc37..d7f2a445 100644 --- a/packages/sdk/src/client/common/utils/billingapi.ts +++ b/packages/sdk/src/client/common/utils/billingapi.ts @@ -37,6 +37,8 @@ export interface BillingApiClientOptions { * When false (default), uses EIP-712 typed data signatures for each request. */ useSession?: boolean; + /** Log request/response details to stderr */ + verbose?: boolean; } /** @@ -191,10 +193,22 @@ export class BillingApiClient { productId: ProductID, body?: Record, ): Promise<{ json: () => Promise; text: () => Promise }> { - if (this.useSession) { - return this.makeSessionAuthenticatedRequest(url, method, body); + if (this.options.verbose) { + console.debug(`[BillingAPI] ${method} ${url}`); } - return this.makeSignatureAuthenticatedRequest(url, method, productId, body); + const resp = this.useSession + ? await this.makeSessionAuthenticatedRequest(url, method, body) + : await this.makeSignatureAuthenticatedRequest(url, method, productId, body); + + if (this.options.verbose) { + const data = await resp.json(); + console.debug(`[BillingAPI] Response:`, JSON.stringify(data, null, 2)); + return { + json: async () => data, + text: async () => JSON.stringify(data), + }; + } + return resp; } /** diff --git a/packages/sdk/src/client/modules/billing/index.ts b/packages/sdk/src/client/modules/billing/index.ts index d68f477d..b424c021 100644 --- a/packages/sdk/src/client/modules/billing/index.ts +++ b/packages/sdk/src/client/modules/billing/index.ts @@ -78,7 +78,7 @@ export function createBillingModule(config: BillingModuleConfig): BillingModule const billingEnvConfig = getBillingEnvironmentConfig(getBuildType()); // Create billing API client - const billingApi = new BillingApiClient(billingEnvConfig, walletClient); + const billingApi = new BillingApiClient(billingEnvConfig, walletClient, { verbose }); // Resolve on-chain config const environmentConfig = getEnvironmentConfig(environment); From 863ec699837e6912eca20fe200e128790cb33343 Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Thu, 23 Apr 2026 16:43:29 -0500 Subject: [PATCH 2/9] feat(sdk): add PaymentMethod and CreditPurchaseResponse types Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/sdk/src/client/common/types/index.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/sdk/src/client/common/types/index.ts b/packages/sdk/src/client/common/types/index.ts index c42a8d71..1a0fda0d 100644 --- a/packages/sdk/src/client/common/types/index.ts +++ b/packages/sdk/src/client/common/types/index.ts @@ -419,6 +419,23 @@ export interface SubscriptionOpts { cancelUrl?: string; } +export interface PaymentMethod { + id: string; + stripePaymentMethodId: string; + createdAt: string; +} + +export interface PaymentMethodsResponse { + paymentMethods: PaymentMethod[]; +} + +export interface CreditPurchaseResponse { + purchaseId?: string; + checkoutSessionId?: string; + checkoutUrl?: string; + amountCents: string; +} + // Billing environment configuration export interface BillingEnvironmentConfig { billingApiServerURL: string; From 605cea5c41cd95185f56c3b002eb421049848a6e Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Thu, 23 Apr 2026 16:44:44 -0500 Subject: [PATCH 3/9] feat(sdk): add getPaymentMethods and purchaseCredits to BillingApiClient Co-Authored-By: Claude Opus 4.6 (1M context) --- .../sdk/src/client/common/utils/billingapi.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/sdk/src/client/common/utils/billingapi.ts b/packages/sdk/src/client/common/utils/billingapi.ts index d7f2a445..46285578 100644 --- a/packages/sdk/src/client/common/utils/billingapi.ts +++ b/packages/sdk/src/client/common/utils/billingapi.ts @@ -18,6 +18,8 @@ import { CreateSubscriptionResponse, GetSubscriptionOptions, ProductSubscriptionResponse, + PaymentMethodsResponse, + CreditPurchaseResponse, } from "../types"; import { calculateBillingAuthSignature } from "./auth"; import { BillingEnvironmentConfig } from "../types"; @@ -178,6 +180,25 @@ export class BillingApiClient { await this.makeAuthenticatedRequest(endpoint, "DELETE", productId); } + async getPaymentMethods(): Promise { + const endpoint = `${this.config.billingApiServerURL}/v1/payment-methods`; + const resp = await this.makeAuthenticatedRequest(endpoint, "GET", "compute"); + return resp.json(); + } + + async purchaseCredits( + amountCents: number, + paymentMethodId?: string, + ): Promise { + const endpoint = `${this.config.billingApiServerURL}/v1/credits/purchase`; + const body: Record = { amountCents }; + if (paymentMethodId) { + body.paymentMethodId = paymentMethodId; + } + const resp = await this.makeAuthenticatedRequest(endpoint, "POST", "compute", body); + return resp.json(); + } + // ========================================================================== // Internal Methods // ========================================================================== From 8046452995cbfba120cbff34e466eadc193591e6 Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Thu, 23 Apr 2026 16:46:05 -0500 Subject: [PATCH 4/9] feat(sdk): expose getPaymentMethods and purchaseCredits on BillingModule Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/sdk/src/client/modules/billing/index.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/sdk/src/client/modules/billing/index.ts b/packages/sdk/src/client/modules/billing/index.ts index b424c021..e07009d3 100644 --- a/packages/sdk/src/client/modules/billing/index.ts +++ b/packages/sdk/src/client/modules/billing/index.ts @@ -23,6 +23,8 @@ import type { SubscribeResponse, CancelResponse, ProductSubscriptionResponse, + PaymentMethodsResponse, + CreditPurchaseResponse, } from "../../common/types"; export interface TopUpOpts { @@ -53,6 +55,8 @@ export interface BillingModule { getTopUpInfo: () => Promise; /** Purchase credits with USDC on-chain */ topUp: (opts: TopUpOpts) => Promise; + getPaymentMethods: () => Promise; + purchaseCredits: (amountCents: number, paymentMethodId?: string) => Promise; } export interface BillingModuleConfig { @@ -281,6 +285,14 @@ export function createBillingModule(config: BillingModuleConfig): BillingModule }, ); }, + + async getPaymentMethods() { + return billingApi.getPaymentMethods(); + }, + + async purchaseCredits(amountCents: number, paymentMethodId?: string) { + return billingApi.purchaseCredits(amountCents, paymentMethodId); + }, }; return module; From 0681564efa3a713c25cb254fd92d94134d1c94da Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Thu, 23 Apr 2026 16:49:34 -0500 Subject: [PATCH 5/9] feat(cli): add credit card payment flow to billing top-up Rewrote the top-up command to support both USDC and credit card payment methods. Users can now choose between on-chain USDC payment or credit card checkout. Changes: - Added method flag to select payment method (usdc or card) - Extracted USDC flow into handleUsdc() method - Added handleCard() method for credit card checkout flow - Added pollForCredits() helper to share polling logic - Updated description and examples - Integrated with new SDK methods: getPaymentMethods and purchaseCredits Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/billing/top-up.ts | 291 +++++++++++++------- 1 file changed, 191 insertions(+), 100 deletions(-) diff --git a/packages/cli/src/commands/billing/top-up.ts b/packages/cli/src/commands/billing/top-up.ts index 7637507b..43ca7c48 100644 --- a/packages/cli/src/commands/billing/top-up.ts +++ b/packages/cli/src/commands/billing/top-up.ts @@ -1,14 +1,15 @@ /** - * ecloud billing top-up — Purchase EigenCompute credits with USDC + * ecloud billing top-up — Purchase EigenCompute credits with USDC or credit card * * Executes USDCCredits.purchaseCreditsFor(amount, account) on-chain via the SDK - * billing module's topUp() method (EIP-7702 batched transaction). + * billing module's topUp() method (EIP-7702 batched transaction), or initiates + * credit card checkout via the purchaseCredits API. * * Flow: * 1. Check current credit balance - * 2. Read wallet's USDC balance via SDK - * 3. If USDC available → prompt for amount → SDK topUp() → poll for confirmation - * 4. If no USDC → show wallet address, tell user to fund it + * 2. Prompt for payment method (USDC or card) + * 3. USDC: Read wallet's USDC balance via SDK → prompt for amount → SDK topUp() → poll + * 4. Card: Prompt for amount → check existing payment methods → purchaseCredits API → poll */ import { Command, Flags } from "@oclif/core"; @@ -16,20 +17,32 @@ import { createBillingClient } from "../../client"; import { commonFlags } from "../../flags"; import { type Address, formatUnits } from "viem"; import chalk from "chalk"; -import { input } from "@inquirer/prompts"; +import { input, select, confirm } from "@inquirer/prompts"; +import open from "open"; import { withTelemetry } from "../../telemetry"; const POLL_INTERVAL_MS = 5_000; const POLL_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes export default class BillingTopUp extends Command { - static description = "Purchase EigenCompute credits with USDC"; + static description = "Purchase EigenCompute credits with USDC or credit card"; + + static examples = [ + "<%= config.bin %> billing top-up", + "<%= config.bin %> billing top-up --method usdc --amount 50", + "<%= config.bin %> billing top-up --method card --amount 25", + ]; static flags = { ...commonFlags, + method: Flags.string({ + required: false, + description: "Payment method: usdc (on-chain) or card (credit card)", + options: ["usdc", "card"], + }), amount: Flags.string({ required: false, - description: "Amount of USDC to spend (e.g., '50')", + description: "Amount to spend (USDC for on-chain, whole dollars for card)", }), account: Flags.string({ required: false, @@ -48,9 +61,7 @@ export default class BillingTopUp extends Command { return withTelemetry(this, async () => { const { flags } = await this.parse(BillingTopUp); - // Create billing client const billing = await createBillingClient(flags); - const walletAddress = billing.address; const targetAccount = (flags.account as Address) ?? walletAddress; @@ -61,9 +72,7 @@ export default class BillingTopUp extends Command { this.log(` ${chalk.bold("Target:")} ${targetAccount}`); } - // ── Step 1: Show current credit balance ── - // Track total credits (remaining + applied) so we detect top-ups even - // when new credits are immediately consumed by an outstanding bill. + // Show current credit balance let baselineTotal: number | undefined; try { const status = await billing.getStatus({ @@ -72,108 +81,190 @@ export default class BillingTopUp extends Command { const remaining = status.remainingCredits ?? 0; const applied = status.creditsApplied ?? 0; baselineTotal = remaining + applied; - if (status.remainingCredits !== undefined) { - this.log(` ${chalk.bold("Credits:")} ${chalk.cyan(`$${status.remainingCredits.toFixed(2)}`)}`); - } + this.log(` ${chalk.bold("Credits:")} ${chalk.cyan(`$${remaining.toFixed(2)}`)}`); } catch { this.debug("Could not fetch current credit balance"); } - // ── Step 2: Read on-chain state via SDK ── - const onChainState = await billing.getTopUpInfo(); - const { usdcBalance, minimumPurchase } = onChainState; - - const balanceFormatted = formatUnits(usdcBalance, 6); - this.log(` ${chalk.bold("USDC:")} ${balanceFormatted} USDC`); + // Select payment method + const method = + flags.method ?? + (await select({ + message: "How would you like to pay?", + choices: [ + { value: "card", name: "Credit card" }, + { value: "usdc", name: "USDC (on-chain)" }, + ], + })); - if (usdcBalance === BigInt(0)) { - this.log(`\n${chalk.yellow(" No USDC in wallet.")}`); - this.log(` Send USDC on Sepolia to: ${chalk.cyan(walletAddress)}`); - this.log(` Then re-run: ${chalk.cyan("ecloud billing top-up")}\n`); - return; + if (method === "usdc") { + await this.handleUsdc(billing, flags, walletAddress, targetAccount, baselineTotal); + } else { + await this.handleCard(billing, flags, baselineTotal); } + }); + } - // ── Step 3: Prompt for amount ── - const minimumFormatted = formatUnits(minimumPurchase, 6); - const amountStr = - flags.amount ?? - (await input({ - message: `How much USDC to spend on credits? (minimum: ${minimumFormatted})`, - validate: (val) => { - const n = parseFloat(val); - if (isNaN(n) || n <= 0) return "Enter a positive number"; - const raw = BigInt(Math.round(n * 1e6)); - if (raw < minimumPurchase) - return `Minimum purchase is ${minimumFormatted} USDC`; - if (raw > usdcBalance) - return `Insufficient balance. You have ${balanceFormatted} USDC`; - return true; - }, - })); + private async handleUsdc( + billing: Awaited>, + flags: Record, + walletAddress: Address, + targetAccount: Address, + baselineTotal: number | undefined, + ) { + const onChainState = await billing.getTopUpInfo(); + const { usdcBalance, minimumPurchase } = onChainState; - const amountFloat = parseFloat(amountStr); - const amountRaw = BigInt(Math.round(amountFloat * 1e6)); + const balanceFormatted = formatUnits(usdcBalance, 6); + this.log(` ${chalk.bold("USDC:")} ${balanceFormatted} USDC`); - if (amountRaw < minimumPurchase) { - this.error(`Minimum purchase is ${minimumFormatted} USDC`); - } - if (amountRaw > usdcBalance) { - this.error( - `Insufficient USDC balance. You have ${balanceFormatted} USDC but requested ${amountFloat.toFixed(2)}`, - ); - } + if (usdcBalance === BigInt(0)) { + this.log(`\n${chalk.yellow(" No USDC in wallet.")}`); + this.log(` Send USDC on Sepolia to: ${chalk.cyan(walletAddress)}`); + this.log(` Then re-run: ${chalk.cyan("ecloud billing top-up")}\n`); + return; + } + + const minimumFormatted = formatUnits(minimumPurchase, 6); + const amountStr = + flags.amount ?? + (await input({ + message: `How much USDC to spend on credits? (minimum: ${minimumFormatted})`, + validate: (val) => { + const n = parseFloat(val); + if (isNaN(n) || n <= 0) return "Enter a positive number"; + const raw = BigInt(Math.round(n * 1e6)); + if (raw < minimumPurchase) + return `Minimum purchase is ${minimumFormatted} USDC`; + if (raw > usdcBalance) + return `Insufficient balance. You have ${balanceFormatted} USDC`; + return true; + }, + })); + + const amountFloat = parseFloat(amountStr); + const amountRaw = BigInt(Math.round(amountFloat * 1e6)); + + if (amountRaw < minimumPurchase) { + this.error(`Minimum purchase is ${minimumFormatted} USDC`); + } + if (amountRaw > usdcBalance) { + this.error( + `Insufficient USDC balance. You have ${balanceFormatted} USDC but requested ${amountFloat.toFixed(2)}`, + ); + } + + this.log(`\n Purchasing ${chalk.bold(`$${amountFloat.toFixed(2)}`)} in credits...`); + + const { txHash } = await billing.topUp({ + amount: amountRaw, + account: targetAccount, + }); + this.log(` ${chalk.green("✓")} Transaction confirmed: ${txHash}`); + + await this.pollForCredits(billing, flags, baselineTotal, amountFloat); + } - this.log(`\n Purchasing ${chalk.bold(`$${amountFloat.toFixed(2)}`)} in credits...`); + private async handleCard( + billing: Awaited>, + flags: Record, + baselineTotal: number | undefined, + ) { + const MINIMUM_DOLLARS = 5; - // ── Step 4: Execute on-chain purchase via SDK ── - const { txHash } = await billing.topUp({ - amount: amountRaw, - account: targetAccount, + // Prompt for amount + const amountStr = + flags.amount ?? + (await input({ + message: `How many dollars of credits to purchase? (minimum: $${MINIMUM_DOLLARS})`, + validate: (val) => { + const n = parseInt(val, 10); + if (isNaN(n) || n <= 0) return "Enter a positive whole number"; + if (n.toString() !== val.trim()) return "Enter a whole dollar amount (no cents)"; + if (n < MINIMUM_DOLLARS) return `Minimum purchase is $${MINIMUM_DOLLARS}`; + return true; + }, + })); + + const dollars = parseInt(amountStr, 10); + if (isNaN(dollars) || dollars < MINIMUM_DOLLARS) { + this.error(`Minimum purchase is $${MINIMUM_DOLLARS}`); + } + const amountCents = dollars * 100; + + // Check for existing payment methods + const { paymentMethods } = await billing.getPaymentMethods(); + + let useExistingCard = false; + let paymentMethodId: string | undefined; + + if (paymentMethods.length > 0) { + const card = paymentMethods[0]; + const lastFour = card.stripePaymentMethodId.slice(-4); + useExistingCard = await confirm({ + message: `Use card on file (ending in ${lastFour})?`, + default: true, }); - this.log(` ${chalk.green("✓")} Transaction confirmed: ${txHash}`); - - // ── Step 5: Poll billing API for credit confirmation ── - this.log(chalk.gray("\n Waiting for credits to appear...")); - const startTime = Date.now(); - while (Date.now() - startTime < POLL_TIMEOUT_MS) { - await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); - try { - const status = await billing.getStatus({ - productId: flags.product as "compute", - }); - const remaining = status.remainingCredits ?? 0; - const applied = status.creditsApplied ?? 0; - const currentTotal = remaining + applied; - this.debug(`Poll: remaining=${remaining}, applied=${applied}, total=${currentTotal}, baseline=${baselineTotal}`); - if ( - baselineTotal === undefined || currentTotal > baselineTotal - ) { - const creditsAdded = baselineTotal !== undefined ? currentTotal - baselineTotal : undefined; - const isMatched = creditsAdded !== undefined && Math.abs(creditsAdded - amountFloat * 2) < 0.01; - const appliedFromTopUp = creditsAdded !== undefined ? creditsAdded - remaining : 0; - - this.log(`\n ${chalk.green("✓")} Credits received: ${chalk.cyan(`$${(creditsAdded ?? amountFloat).toFixed(2)}`)}`); - if (isMatched) { - this.log(` ${chalk.green("✓")} Includes $${amountFloat.toFixed(2)} match bonus!`); - } - if (remaining > 0) { - this.log(` Remaining balance: ${chalk.cyan(`$${remaining.toFixed(2)}`)}`); - } - if (appliedFromTopUp > 0) { - this.log(` ${chalk.gray(`$${appliedFromTopUp.toFixed(2)} applied to current bill`)}`); - } - this.log(); - return; + if (useExistingCard) { + paymentMethodId = card.id; + } + } + + this.log(`\n Purchasing ${chalk.bold(`$${dollars}`)} in credits...`); + + const result = await billing.purchaseCredits(amountCents, paymentMethodId); + + if (result.checkoutUrl) { + this.log(`\n ${chalk.cyan(result.checkoutUrl)}`); + this.log(chalk.gray(" Opening checkout in browser...")); + await open(result.checkoutUrl); + } else { + this.log(` ${chalk.green("✓")} Payment submitted`); + } + + await this.pollForCredits(billing, flags, baselineTotal, dollars); + } + + private async pollForCredits( + billing: Awaited>, + flags: Record, + baselineTotal: number | undefined, + amountPurchased: number, + ) { + this.log(chalk.gray("\n Waiting for credits to appear...")); + const startTime = Date.now(); + while (Date.now() - startTime < POLL_TIMEOUT_MS) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + try { + const status = await billing.getStatus({ + productId: flags.product as "compute", + }); + const remaining = status.remainingCredits ?? 0; + const applied = status.creditsApplied ?? 0; + const currentTotal = remaining + applied; + this.debug( + `Poll: remaining=${remaining}, applied=${applied}, total=${currentTotal}, baseline=${baselineTotal}`, + ); + if (baselineTotal === undefined || currentTotal > baselineTotal) { + const creditsAdded = + baselineTotal !== undefined ? currentTotal - baselineTotal : undefined; + this.log( + `\n ${chalk.green("✓")} Credits received: ${chalk.cyan(`$${(creditsAdded ?? amountPurchased).toFixed(2)}`)}`, + ); + if (remaining > 0) { + this.log(` Remaining balance: ${chalk.cyan(`$${remaining.toFixed(2)}`)}`); } - } catch { - this.debug("Error polling for credit balance"); + this.log(); + return; } + } catch { + this.debug("Error polling for credit balance"); } + } - this.log( - `\n ${chalk.yellow("⚠")} Credits haven't appeared yet. This can take a few minutes.`, - ); - this.log(` ${chalk.gray("Check your balance:")} ecloud billing status\n`); - }); + this.log( + `\n ${chalk.yellow("⚠")} Credits haven't appeared yet. This can take a few minutes.`, + ); + this.log(` ${chalk.gray("Check your balance:")} ecloud billing status\n`); } } From c3662476f6e05a395a087b542b851ecf68505923 Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Thu, 23 Apr 2026 16:52:24 -0500 Subject: [PATCH 6/9] test(cli): add credit card flow tests for billing top-up Co-Authored-By: Claude Opus 4.6 (1M context) --- .../commands/billing/__tests__/top-up.test.ts | 158 +++++++++++++++--- 1 file changed, 135 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/commands/billing/__tests__/top-up.test.ts b/packages/cli/src/commands/billing/__tests__/top-up.test.ts index 986e02f2..9c38fdaf 100644 --- a/packages/cli/src/commands/billing/__tests__/top-up.test.ts +++ b/packages/cli/src/commands/billing/__tests__/top-up.test.ts @@ -10,11 +10,17 @@ vi.mock("../../../telemetry", () => ({ vi.mock("@inquirer/prompts", () => ({ input: vi.fn(), + select: vi.fn(), + confirm: vi.fn(), +})); + +vi.mock("open", () => ({ + default: vi.fn(), })); import BillingTopUp from "../top-up"; import { createBillingClient } from "../../../client"; -import { input } from "@inquirer/prompts"; +import { input, select, confirm } from "@inquirer/prompts"; const WALLET_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"; const TX_HASH = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; @@ -26,6 +32,8 @@ describe("ecloud billing top-up", () => { getStatus: ReturnType; getTopUpInfo: ReturnType; topUp: ReturnType; + getPaymentMethods: ReturnType; + purchaseCredits: ReturnType; }; beforeEach(() => { @@ -37,6 +45,8 @@ describe("ecloud billing top-up", () => { getStatus: vi.fn(), getTopUpInfo: vi.fn(), topUp: vi.fn(), + getPaymentMethods: vi.fn(), + purchaseCredits: vi.fn(), }; (createBillingClient as ReturnType).mockResolvedValue(mockBilling); @@ -86,6 +96,8 @@ describe("ecloud billing top-up", () => { return cmd; } + // ── USDC Tests ── + it("happy path: sufficient balance, purchase succeeds", async () => { setupOnChainState(); mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); @@ -93,29 +105,22 @@ describe("ecloud billing top-up", () => { .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); - const cmd = createCommand({ amount: "50" }); + const cmd = createCommand({ amount: "50", method: "usdc" }); const promise = cmd.run(); - // Advance timers to resolve the polling setTimeout for (let i = 0; i < 10; i++) { await vi.advanceTimersByTimeAsync(5_000); } await promise; const fullOutput = logOutput.join("\n"); - // Shows wallet address expect(fullOutput).toContain(WALLET_ADDRESS); - // Shows credits expect(fullOutput).toContain("$10.00"); - // Shows USDC balance expect(fullOutput).toContain("100 USDC"); - // Shows purchase step expect(fullOutput).toContain("Purchasing"); expect(fullOutput).toContain("Transaction confirmed"); - // Shows final balance after polling expect(fullOutput).toContain("Credits received"); expect(fullOutput).toContain("$60.00"); - // Verify topUp was called with correct args expect(mockBilling.topUp).toHaveBeenCalledWith({ amount: BigInt(50_000_000), account: WALLET_ADDRESS, @@ -126,7 +131,7 @@ describe("ecloud billing top-up", () => { setupOnChainState({ usdcBalance: BigInt(0) }); mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "inactive" }); - const cmd = createCommand({ amount: "50" }); + const cmd = createCommand({ amount: "50", method: "usdc" }); await cmd.run(); const fullOutput = logOutput.join("\n"); @@ -134,7 +139,6 @@ describe("ecloud billing top-up", () => { expect(fullOutput).toContain("Send USDC on Sepolia to"); expect(fullOutput).toContain(WALLET_ADDRESS); - // Should not have called topUp expect(mockBilling.topUp).not.toHaveBeenCalled(); }); @@ -142,7 +146,7 @@ describe("ecloud billing top-up", () => { setupOnChainState({ minimumPurchase: BigInt(10_000_000) }); // 10 USDC minimum mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "inactive" }); - const cmd = createCommand({ amount: "5" }); + const cmd = createCommand({ amount: "5", method: "usdc" }); await expect(cmd.run()).rejects.toThrow("Minimum purchase is 10 USDC"); }); @@ -154,7 +158,7 @@ describe("ecloud billing top-up", () => { .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); - const cmd = createCommand({ amount: "50", account: targetAccount }); + const cmd = createCommand({ amount: "50", method: "usdc", account: targetAccount }); const promise = cmd.run(); for (let i = 0; i < 10; i++) { await vi.advanceTimersByTimeAsync(5_000); @@ -162,10 +166,8 @@ describe("ecloud billing top-up", () => { await promise; const fullOutput = logOutput.join("\n"); - // Shows target account expect(fullOutput).toContain(targetAccount); - // Verify topUp was called with the target account expect(mockBilling.topUp).toHaveBeenCalledWith({ amount: BigInt(50_000_000), account: targetAccount, @@ -175,15 +177,13 @@ describe("ecloud billing top-up", () => { it("billing API poll timeout: shows timeout message", async () => { setupOnChainState(); mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); - // getStatus always returns the same credits (no increase) mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0, }); - const cmd = createCommand({ amount: "50" }); + const cmd = createCommand({ amount: "50", method: "usdc" }); const promise = cmd.run(); - // Advance past the 3-minute poll timeout await vi.advanceTimersByTimeAsync(200_000); await promise; const fullOutput = logOutput.join("\n"); @@ -199,7 +199,7 @@ describe("ecloud billing top-up", () => { .mockResolvedValueOnce({ subscriptionStatus: "inactive" }) .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 100.0 }); - const cmd = createCommand({ amount: "100" }); + const cmd = createCommand({ amount: "100", method: "usdc" }); const promise = cmd.run(); for (let i = 0; i < 10; i++) { await vi.advanceTimersByTimeAsync(5_000); @@ -214,17 +214,129 @@ describe("ecloud billing top-up", () => { mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); mockBilling.getStatus.mockRejectedValue(new Error("API unavailable")); - const cmd = createCommand({ amount: "50" }); + const cmd = createCommand({ amount: "50", method: "usdc" }); const promise = cmd.run(); - // Advance past poll timeout since getStatus always errors await vi.advanceTimersByTimeAsync(200_000); await promise; const fullOutput = logOutput.join("\n"); - // Should still proceed with on-chain purchase expect(fullOutput).toContain("Purchasing"); expect(fullOutput).toContain("Transaction confirmed"); - // Will timeout on polling since status always errors expect(fullOutput).toContain("Credits haven't appeared yet"); }); + + // ── Credit Card Tests ── + + it("credit card: charges existing card on file", async () => { + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 35.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ + paymentMethods: [ + { + id: "029641fc-3e5c-11f1-986c-5601121cbf6d", + stripePaymentMethodId: "pm_1ABC1234", + createdAt: "2026-04-20T15:00:00Z", + }, + ], + }); + mockBilling.purchaseCredits.mockResolvedValue({ + purchaseId: "a1b2c3d4", + amountCents: "2500", + }); + (confirm as unknown as ReturnType).mockResolvedValue(true); + + const cmd = createCommand({ amount: "25", method: "card" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + const fullOutput = logOutput.join("\n"); + + expect(mockBilling.purchaseCredits).toHaveBeenCalledWith(2500, "029641fc-3e5c-11f1-986c-5601121cbf6d"); + expect(fullOutput).toContain("Payment submitted"); + expect(fullOutput).toContain("Credits received"); + }); + + it("credit card: opens checkout when user declines existing card", async () => { + const openMock = (await import("open")).default as ReturnType; + mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ + paymentMethods: [ + { + id: "029641fc-3e5c-11f1-986c-5601121cbf6d", + stripePaymentMethodId: "pm_1ABC1234", + createdAt: "2026-04-20T15:00:00Z", + }, + ], + }); + mockBilling.purchaseCredits.mockResolvedValue({ + checkoutSessionId: "cs_test_abc123", + checkoutUrl: "https://checkout.stripe.com/test", + amountCents: "2500", + }); + (confirm as unknown as ReturnType).mockResolvedValue(false); + + const cmd = createCommand({ amount: "25", method: "card" }); + const promise = cmd.run(); + await vi.advanceTimersByTimeAsync(200_000); + await promise; + const fullOutput = logOutput.join("\n"); + + expect(mockBilling.purchaseCredits).toHaveBeenCalledWith(2500, undefined); + expect(openMock).toHaveBeenCalledWith("https://checkout.stripe.com/test"); + expect(fullOutput).toContain("https://checkout.stripe.com/test"); + }); + + it("credit card: opens checkout when no card on file", async () => { + const openMock = (await import("open")).default as ReturnType; + mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ paymentMethods: [] }); + mockBilling.purchaseCredits.mockResolvedValue({ + checkoutSessionId: "cs_test_abc123", + checkoutUrl: "https://checkout.stripe.com/test", + amountCents: "5000", + }); + + const cmd = createCommand({ amount: "50", method: "card" }); + const promise = cmd.run(); + await vi.advanceTimersByTimeAsync(200_000); + await promise; + const fullOutput = logOutput.join("\n"); + + expect(confirm).not.toHaveBeenCalled(); + expect(mockBilling.purchaseCredits).toHaveBeenCalledWith(5000, undefined); + expect(openMock).toHaveBeenCalledWith("https://checkout.stripe.com/test"); + expect(fullOutput).toContain("https://checkout.stripe.com/test"); + }); + + it("credit card: rejects amount below $5 minimum", async () => { + mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0 }); + + const cmd = createCommand({ amount: "3", method: "card" }); + await expect(cmd.run()).rejects.toThrow("Minimum purchase is $5"); + }); + + it("credit card: --method and --amount flags skip prompts", async () => { + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ paymentMethods: [] }); + mockBilling.purchaseCredits.mockResolvedValue({ + checkoutSessionId: "cs_test_abc123", + checkoutUrl: "https://checkout.stripe.com/test", + amountCents: "5000", + }); + + const cmd = createCommand({ amount: "50", method: "card" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + + expect(select).not.toHaveBeenCalled(); + expect(input).not.toHaveBeenCalled(); + }); }); From df0d553544bdda389d058de2173de25c3ea7db94 Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Wed, 29 Apr 2026 10:21:10 -0500 Subject: [PATCH 7/9] execution plan --- .../plans/2026-04-23-top-up-credit-card.md | 770 ++++++++++++++++++ .../2026-04-23-top-up-credit-card-design.md | 211 +++++ 2 files changed, 981 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-23-top-up-credit-card.md create mode 100644 docs/superpowers/specs/2026-04-23-top-up-credit-card-design.md diff --git a/docs/superpowers/plans/2026-04-23-top-up-credit-card.md b/docs/superpowers/plans/2026-04-23-top-up-credit-card.md new file mode 100644 index 00000000..0f391169 --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-top-up-credit-card.md @@ -0,0 +1,770 @@ +# Top-Up Credit Card Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add credit card payment support to `ecloud billing top-up` alongside the existing USDC on-chain flow. + +**Architecture:** Two new SDK methods (`getPaymentMethods`, `purchaseCredits`) on the existing `BillingApiClient` call the `/v1/payment-methods` and `/v1/credits/purchase` endpoints using the same EIP-712 auth. The CLI `top-up` command gets a `--method` flag and branches into the existing USDC path or a new credit card path with card-on-file detection. + +**Tech Stack:** TypeScript, oclif (CLI framework), viem (wallet), vitest (tests), `open` package (browser), `@inquirer/prompts` (interactive prompts) + +**Spec:** `docs/superpowers/specs/2026-04-23-top-up-credit-card-design.md` + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `packages/sdk/src/client/common/types/index.ts` | Modify | Add `PaymentMethod`, `PaymentMethodsResponse`, `CreditPurchaseResponse` types | +| `packages/sdk/src/client/common/utils/billingapi.ts` | Modify | Add `getPaymentMethods()` and `purchaseCredits()` methods to `BillingApiClient` | +| `packages/sdk/src/client/modules/billing/index.ts` | Modify | Expose new methods on `BillingModule` interface and wire them in `createBillingModule` | +| `packages/cli/src/commands/billing/top-up.ts` | Modify | Add `--method` flag, payment method selection prompt, credit card purchase flow | +| `packages/cli/src/commands/billing/__tests__/top-up.test.ts` | Modify | Add credit card flow test cases | + +--- + +## Task 1: Add new types to SDK + +**Files:** +- Modify: `packages/sdk/src/client/common/types/index.ts:420-425` (after `SubscriptionOpts`, before `BillingEnvironmentConfig`) + +- [ ] **Step 1: Add the new type definitions** + +Insert after the `SubscriptionOpts` interface (line 420) and before the `BillingEnvironmentConfig` interface (line 422): + +```typescript +export interface PaymentMethod { + id: string; + stripePaymentMethodId: string; + createdAt: string; +} + +export interface PaymentMethodsResponse { + paymentMethods: PaymentMethod[]; +} + +export interface CreditPurchaseResponse { + purchaseId?: string; + checkoutSessionId?: string; + checkoutUrl?: string; + amountCents: string; +} +``` + +- [ ] **Step 2: Verify types compile** + +Run: `npx tsc --noEmit -p packages/sdk/tsconfig.json` +Expected: Clean exit, no errors. + +- [ ] **Step 3: Commit** + +```bash +git add packages/sdk/src/client/common/types/index.ts +git commit -m "feat(sdk): add PaymentMethod and CreditPurchaseResponse types" +``` + +--- + +## Task 2: Add `getPaymentMethods()` and `purchaseCredits()` to `BillingApiClient` + +**Files:** +- Modify: `packages/sdk/src/client/common/utils/billingapi.ts:176-179` (after `cancelSubscription`, before the Internal Methods section) + +- [ ] **Step 1: Add the import for new types** + +In `billingapi.ts`, add `PaymentMethodsResponse` and `CreditPurchaseResponse` to the existing import from `"../types"`: + +```typescript +import { + ProductID, + CreateSubscriptionOptions, + CreateSubscriptionResponse, + GetSubscriptionOptions, + ProductSubscriptionResponse, + PaymentMethodsResponse, + CreditPurchaseResponse, +} from "../types"; +``` + +- [ ] **Step 2: Add `getPaymentMethods()` method** + +Insert after `cancelSubscription` (line 178) and before the `// Internal Methods` comment (line 181): + +```typescript + async getPaymentMethods(): Promise { + const endpoint = `${this.config.billingApiServerURL}/v1/payment-methods`; + const resp = await this.makeAuthenticatedRequest(endpoint, "GET", "compute"); + return resp.json(); + } + + async purchaseCredits( + amountCents: number, + paymentMethodId?: string, + ): Promise { + const endpoint = `${this.config.billingApiServerURL}/v1/credits/purchase`; + const body: Record = { amountCents }; + if (paymentMethodId) { + body.paymentMethodId = paymentMethodId; + } + const resp = await this.makeAuthenticatedRequest(endpoint, "POST", "compute", body); + return resp.json(); + } +``` + +- [ ] **Step 3: Verify types compile** + +Run: `npx tsc --noEmit -p packages/sdk/tsconfig.json` +Expected: Clean exit, no errors. + +- [ ] **Step 4: Commit** + +```bash +git add packages/sdk/src/client/common/utils/billingapi.ts +git commit -m "feat(sdk): add getPaymentMethods and purchaseCredits to BillingApiClient" +``` + +--- + +## Task 3: Expose new methods on `BillingModule` + +**Files:** +- Modify: `packages/sdk/src/client/modules/billing/index.ts:47-56` (BillingModule interface) and `~90` (module object in `createBillingModule`) + +- [ ] **Step 1: Add imports for new types** + +Add `PaymentMethodsResponse` and `CreditPurchaseResponse` to the import from `"../../common/types"`: + +```typescript +import type { + ProductID, + SubscriptionOpts, + SubscribeResponse, + CancelResponse, + ProductSubscriptionResponse, + PaymentMethodsResponse, + CreditPurchaseResponse, +} from "../../common/types"; +``` + +- [ ] **Step 2: Extend the `BillingModule` interface** + +Add these two methods to the `BillingModule` interface (after the `topUp` method, before the closing brace): + +```typescript + getPaymentMethods: () => Promise; + purchaseCredits: (amountCents: number, paymentMethodId?: string) => Promise; +``` + +- [ ] **Step 3: Wire the methods in `createBillingModule`** + +Inside the `const module: BillingModule = { ... }` object, after the `cancel` method definition (around line 283), add: + +```typescript + async getPaymentMethods() { + return billingApi.getPaymentMethods(); + }, + + async purchaseCredits(amountCents: number, paymentMethodId?: string) { + return billingApi.purchaseCredits(amountCents, paymentMethodId); + }, +``` + +- [ ] **Step 4: Verify types compile** + +Run: `npx tsc --noEmit -p packages/sdk/tsconfig.json` +Expected: Clean exit, no errors. + +- [ ] **Step 5: Commit** + +```bash +git add packages/sdk/src/client/modules/billing/index.ts +git commit -m "feat(sdk): expose getPaymentMethods and purchaseCredits on BillingModule" +``` + +--- + +## Task 4: Add credit card flow to `top-up.ts` CLI command + +**Files:** +- Modify: `packages/cli/src/commands/billing/top-up.ts` + +- [ ] **Step 1: Update imports** + +Replace the existing imports at the top of the file: + +```typescript +import { Command, Flags } from "@oclif/core"; +import { createBillingClient } from "../../client"; +import { commonFlags } from "../../flags"; +import { type Address, formatUnits } from "viem"; +import chalk from "chalk"; +import { input, select, confirm } from "@inquirer/prompts"; +import open from "open"; +import { withTelemetry } from "../../telemetry"; +``` + +Note: `select` and `confirm` are added from `@inquirer/prompts`; `open` is added for opening checkout URLs in browser. + +- [ ] **Step 2: Update command description and add `--method` flag** + +Update the static properties on the class: + +```typescript +export default class BillingTopUp extends Command { + static description = "Purchase EigenCompute credits with USDC or credit card"; + + static examples = [ + "<%= config.bin %> billing top-up", + "<%= config.bin %> billing top-up --method usdc --amount 50", + "<%= config.bin %> billing top-up --method card --amount 25", + ]; + + static flags = { + ...commonFlags, + method: Flags.string({ + required: false, + description: "Payment method: usdc (on-chain) or card (credit card)", + options: ["usdc", "card"], + }), + amount: Flags.string({ + required: false, + description: "Amount to spend (USDC for on-chain, whole dollars for card)", + }), + account: Flags.string({ + required: false, + description: "Target account address for purchaseCreditsFor (defaults to your wallet)", + }), + product: Flags.string({ + required: false, + description: "Product ID", + default: "compute", + options: ["compute"], + env: "ECLOUD_PRODUCT_ID", + }), + }; +``` + +- [ ] **Step 3: Update the `run()` method — payment method selection and branching** + +Replace the entire `run()` method body. The structure is: + +1. Create billing client, show wallet info and current credits (unchanged). +2. Select payment method (prompt or flag). +3. Branch to USDC path or credit card path. + +```typescript + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(BillingTopUp); + + const billing = await createBillingClient(flags); + const walletAddress = billing.address; + const targetAccount = (flags.account as Address) ?? walletAddress; + + this.log(`\n${chalk.bold("Purchase EigenCompute credits")}`); + this.log(`${chalk.gray("─".repeat(45))}`); + this.log(`\n ${chalk.bold("Wallet:")} ${walletAddress}`); + if (targetAccount !== walletAddress) { + this.log(` ${chalk.bold("Target:")} ${targetAccount}`); + } + + // Show current credit balance + let baselineTotal: number | undefined; + try { + const status = await billing.getStatus({ + productId: flags.product as "compute", + }); + const remaining = status.remainingCredits ?? 0; + const applied = status.creditsApplied ?? 0; + baselineTotal = remaining + applied; + this.log(` ${chalk.bold("Credits:")} ${chalk.cyan(`$${remaining.toFixed(2)}`)}`); + } catch { + this.debug("Could not fetch current credit balance"); + } + + // Select payment method + const method = + flags.method ?? + (await select({ + message: "How would you like to pay?", + choices: [ + { value: "card", name: "Credit card" }, + { value: "usdc", name: "USDC (on-chain)" }, + ], + })); + + if (method === "usdc") { + await this.handleUsdc(billing, flags, walletAddress, targetAccount, baselineTotal); + } else { + await this.handleCard(billing, flags, baselineTotal); + } + }); + } +``` + +- [ ] **Step 4: Extract USDC path into `handleUsdc` method** + +Add this private method. This is the existing USDC flow extracted with no logic changes: + +```typescript + private async handleUsdc( + billing: Awaited>, + flags: Record, + walletAddress: Address, + targetAccount: Address, + baselineTotal: number | undefined, + ) { + const onChainState = await billing.getTopUpInfo(); + const { usdcBalance, minimumPurchase } = onChainState; + + const balanceFormatted = formatUnits(usdcBalance, 6); + this.log(` ${chalk.bold("USDC:")} ${balanceFormatted} USDC`); + + if (usdcBalance === BigInt(0)) { + this.log(`\n${chalk.yellow(" No USDC in wallet.")}`); + this.log(` Send USDC on Sepolia to: ${chalk.cyan(walletAddress)}`); + this.log(` Then re-run: ${chalk.cyan("ecloud billing top-up")}\n`); + return; + } + + const minimumFormatted = formatUnits(minimumPurchase, 6); + const amountStr = + flags.amount ?? + (await input({ + message: `How much USDC to spend on credits? (minimum: ${minimumFormatted})`, + validate: (val) => { + const n = parseFloat(val); + if (isNaN(n) || n <= 0) return "Enter a positive number"; + const raw = BigInt(Math.round(n * 1e6)); + if (raw < minimumPurchase) + return `Minimum purchase is ${minimumFormatted} USDC`; + if (raw > usdcBalance) + return `Insufficient balance. You have ${balanceFormatted} USDC`; + return true; + }, + })); + + const amountFloat = parseFloat(amountStr); + const amountRaw = BigInt(Math.round(amountFloat * 1e6)); + + if (amountRaw < minimumPurchase) { + this.error(`Minimum purchase is ${minimumFormatted} USDC`); + } + if (amountRaw > usdcBalance) { + this.error( + `Insufficient USDC balance. You have ${balanceFormatted} USDC but requested ${amountFloat.toFixed(2)}`, + ); + } + + this.log(`\n Purchasing ${chalk.bold(`$${amountFloat.toFixed(2)}`)} in credits...`); + + const { txHash } = await billing.topUp({ + amount: amountRaw, + account: targetAccount, + }); + this.log(` ${chalk.green("✓")} Transaction confirmed: ${txHash}`); + + await this.pollForCredits(billing, flags, baselineTotal, amountFloat); + } +``` + +- [ ] **Step 5: Add `handleCard` method** + +```typescript + private async handleCard( + billing: Awaited>, + flags: Record, + baselineTotal: number | undefined, + ) { + const MINIMUM_DOLLARS = 5; + + // Prompt for amount + const amountStr = + flags.amount ?? + (await input({ + message: `How many dollars of credits to purchase? (minimum: $${MINIMUM_DOLLARS})`, + validate: (val) => { + const n = parseInt(val, 10); + if (isNaN(n) || n <= 0) return "Enter a positive whole number"; + if (n.toString() !== val.trim()) return "Enter a whole dollar amount (no cents)"; + if (n < MINIMUM_DOLLARS) return `Minimum purchase is $${MINIMUM_DOLLARS}`; + return true; + }, + })); + + const dollars = parseInt(amountStr, 10); + if (isNaN(dollars) || dollars < MINIMUM_DOLLARS) { + this.error(`Minimum purchase is $${MINIMUM_DOLLARS}`); + } + const amountCents = dollars * 100; + + // Check for existing payment methods + const { paymentMethods } = await billing.getPaymentMethods(); + + let useExistingCard = false; + let paymentMethodId: string | undefined; + + if (paymentMethods.length > 0) { + const card = paymentMethods[0]; + const lastFour = card.stripePaymentMethodId.slice(-4); + useExistingCard = await confirm({ + message: `Use card on file (ending in ${lastFour})?`, + default: true, + }); + if (useExistingCard) { + paymentMethodId = card.id; + } + } + + this.log(`\n Purchasing ${chalk.bold(`$${dollars}`)} in credits...`); + + const result = await billing.purchaseCredits(amountCents, paymentMethodId); + + if (result.checkoutUrl) { + this.log(`\n ${chalk.cyan(result.checkoutUrl)}`); + this.log(chalk.gray(" Opening checkout in browser...")); + await open(result.checkoutUrl); + } else { + this.log(` ${chalk.green("✓")} Payment submitted`); + } + + await this.pollForCredits(billing, flags, baselineTotal, dollars); + } +``` + +- [ ] **Step 6: Extract shared polling into `pollForCredits` method** + +```typescript + private async pollForCredits( + billing: Awaited>, + flags: Record, + baselineTotal: number | undefined, + amountPurchased: number, + ) { + this.log(chalk.gray("\n Waiting for credits to appear...")); + const startTime = Date.now(); + while (Date.now() - startTime < POLL_TIMEOUT_MS) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + try { + const status = await billing.getStatus({ + productId: flags.product as "compute", + }); + const remaining = status.remainingCredits ?? 0; + const applied = status.creditsApplied ?? 0; + const currentTotal = remaining + applied; + this.debug( + `Poll: remaining=${remaining}, applied=${applied}, total=${currentTotal}, baseline=${baselineTotal}`, + ); + if (baselineTotal === undefined || currentTotal > baselineTotal) { + const creditsAdded = + baselineTotal !== undefined ? currentTotal - baselineTotal : undefined; + this.log( + `\n ${chalk.green("✓")} Credits received: ${chalk.cyan(`$${(creditsAdded ?? amountPurchased).toFixed(2)}`)}`, + ); + if (remaining > 0) { + this.log(` Remaining balance: ${chalk.cyan(`$${remaining.toFixed(2)}`)}`); + } + this.log(); + return; + } + } catch { + this.debug("Error polling for credit balance"); + } + } + + this.log( + `\n ${chalk.yellow("⚠")} Credits haven't appeared yet. This can take a few minutes.`, + ); + this.log(` ${chalk.gray("Check your balance:")} ecloud billing status\n`); + } +``` + +- [ ] **Step 7: Verify types compile** + +Run: `npx tsc --noEmit -p packages/cli/tsconfig.json` +Expected: Clean exit, no errors. + +- [ ] **Step 8: Commit** + +```bash +git add packages/cli/src/commands/billing/top-up.ts +git commit -m "feat(cli): add credit card payment flow to billing top-up" +``` + +--- + +## Task 5: Update tests for credit card flow + +**Files:** +- Modify: `packages/cli/src/commands/billing/__tests__/top-up.test.ts` + +- [ ] **Step 1: Update mocks to include new imports** + +Replace the mock setup at the top of the file. The `@inquirer/prompts` mock needs `select` and `confirm` added; `open` needs to be mocked: + +```typescript +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("../../../client", () => ({ + createBillingClient: vi.fn(), +})); + +vi.mock("../../../telemetry", () => ({ + withTelemetry: vi.fn((_cmd: unknown, fn: () => Promise) => fn()), +})); + +vi.mock("@inquirer/prompts", () => ({ + input: vi.fn(), + select: vi.fn(), + confirm: vi.fn(), +})); + +vi.mock("open", () => ({ + default: vi.fn(), +})); + +import BillingTopUp from "../top-up"; +import { createBillingClient } from "../../../client"; +import { input, select, confirm } from "@inquirer/prompts"; +``` + +- [ ] **Step 2: Update `mockBilling` in `beforeEach` to include new methods** + +```typescript + let mockBilling: { + address: string; + getStatus: ReturnType; + getTopUpInfo: ReturnType; + topUp: ReturnType; + getPaymentMethods: ReturnType; + purchaseCredits: ReturnType; + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + logOutput = []; + mockBilling = { + address: WALLET_ADDRESS, + getStatus: vi.fn(), + getTopUpInfo: vi.fn(), + topUp: vi.fn(), + getPaymentMethods: vi.fn(), + purchaseCredits: vi.fn(), + }; + (createBillingClient as ReturnType).mockResolvedValue(mockBilling); + + (input as ReturnType).mockResolvedValue("50"); + }); +``` + +- [ ] **Step 3: Update existing USDC tests** + +The existing tests need to set `flags.method: "usdc"` since the flow now branches on method. Update the `createCommand` helper: + +```typescript + function createCommand(flags: Record = {}) { + const cmd = new BillingTopUp([], {} as any); + cmd.parse = vi.fn().mockResolvedValue({ + flags: { + product: "compute", + "private-key": "0xdeadbeef", + environment: "sepolia-dev", + ...flags, + }, + }); + cmd.log = vi.fn((...args: string[]) => logOutput.push(args.join(" "))); + cmd.debug = vi.fn(); + cmd.error = vi.fn((msg: string) => { + throw new Error(msg); + }) as any; + return cmd; + } +``` + +For each existing test that passes `amount` as a flag, also add `method: "usdc"`. For example, the "happy path" test becomes: + +```typescript + const cmd = createCommand({ amount: "50", method: "usdc" }); +``` + +Apply this change to all existing tests: +- "happy path: sufficient balance, purchase succeeds" → `{ amount: "50", method: "usdc" }` +- "zero USDC balance: exits with fund wallet message" → `{ amount: "50", method: "usdc" }` +- "below minimum purchase: shows error" → `{ amount: "5", method: "usdc" }` +- "--account flag: passes different address to topUp" → `{ amount: "50", method: "usdc", account: targetAccount }` +- "billing API poll timeout: shows timeout message" → `{ amount: "50", method: "usdc" }` +- "uses --amount flag when provided (skips prompt)" → `{ amount: "100", method: "usdc" }` +- "does not fail if status check errors" → `{ amount: "50", method: "usdc" }` + +- [ ] **Step 4: Run existing USDC tests to make sure they still pass** + +Run: `npx vitest run packages/cli/src/commands/billing/__tests__/top-up.test.ts` +Expected: All existing tests pass. + +- [ ] **Step 5: Add credit card test — card on file, user accepts** + +```typescript + it("credit card: charges existing card on file", async () => { + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 35.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ + paymentMethods: [ + { + id: "029641fc-3e5c-11f1-986c-5601121cbf6d", + stripePaymentMethodId: "pm_1ABC1234", + createdAt: "2026-04-20T15:00:00Z", + }, + ], + }); + mockBilling.purchaseCredits.mockResolvedValue({ + purchaseId: "a1b2c3d4", + amountCents: "2500", + }); + (confirm as unknown as ReturnType).mockResolvedValue(true); + + const cmd = createCommand({ amount: "25", method: "card" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + const fullOutput = logOutput.join("\n"); + + expect(mockBilling.purchaseCredits).toHaveBeenCalledWith(2500, "029641fc-3e5c-11f1-986c-5601121cbf6d"); + expect(fullOutput).toContain("Payment submitted"); + expect(fullOutput).toContain("Credits received"); + }); +``` + +- [ ] **Step 6: Add credit card test — card on file, user declines (wants new card)** + +```typescript + it("credit card: opens checkout when user declines existing card", async () => { + const openMock = (await import("open")).default as ReturnType; + mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ + paymentMethods: [ + { + id: "029641fc-3e5c-11f1-986c-5601121cbf6d", + stripePaymentMethodId: "pm_1ABC1234", + createdAt: "2026-04-20T15:00:00Z", + }, + ], + }); + mockBilling.purchaseCredits.mockResolvedValue({ + checkoutSessionId: "cs_test_abc123", + checkoutUrl: "https://checkout.stripe.com/test", + amountCents: "2500", + }); + (confirm as unknown as ReturnType).mockResolvedValue(false); + + const cmd = createCommand({ amount: "25", method: "card" }); + const promise = cmd.run(); + await vi.advanceTimersByTimeAsync(200_000); + await promise; + const fullOutput = logOutput.join("\n"); + + expect(mockBilling.purchaseCredits).toHaveBeenCalledWith(2500, undefined); + expect(openMock).toHaveBeenCalledWith("https://checkout.stripe.com/test"); + expect(fullOutput).toContain("https://checkout.stripe.com/test"); + }); +``` + +- [ ] **Step 7: Add credit card test — no card on file** + +```typescript + it("credit card: opens checkout when no card on file", async () => { + const openMock = (await import("open")).default as ReturnType; + mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ paymentMethods: [] }); + mockBilling.purchaseCredits.mockResolvedValue({ + checkoutSessionId: "cs_test_abc123", + checkoutUrl: "https://checkout.stripe.com/test", + amountCents: "5000", + }); + + const cmd = createCommand({ amount: "50", method: "card" }); + const promise = cmd.run(); + await vi.advanceTimersByTimeAsync(200_000); + await promise; + const fullOutput = logOutput.join("\n"); + + expect(confirm).not.toHaveBeenCalled(); + expect(mockBilling.purchaseCredits).toHaveBeenCalledWith(5000, undefined); + expect(openMock).toHaveBeenCalledWith("https://checkout.stripe.com/test"); + expect(fullOutput).toContain("https://checkout.stripe.com/test"); + }); +``` + +- [ ] **Step 8: Add credit card test — amount below $5 minimum** + +```typescript + it("credit card: rejects amount below $5 minimum", async () => { + mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0 }); + + const cmd = createCommand({ amount: "3", method: "card" }); + await expect(cmd.run()).rejects.toThrow("Minimum purchase is $5"); + }); +``` + +- [ ] **Step 9: Add credit card test — `--method card --amount 50` skips prompts** + +```typescript + it("credit card: --method and --amount flags skip prompts", async () => { + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ paymentMethods: [] }); + mockBilling.purchaseCredits.mockResolvedValue({ + checkoutSessionId: "cs_test_abc123", + checkoutUrl: "https://checkout.stripe.com/test", + amountCents: "5000", + }); + + const cmd = createCommand({ amount: "50", method: "card" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + + expect(select).not.toHaveBeenCalled(); + expect(input).not.toHaveBeenCalled(); + }); +``` + +- [ ] **Step 10: Run all tests** + +Run: `npx vitest run packages/cli/src/commands/billing/__tests__/top-up.test.ts` +Expected: All tests pass (existing USDC tests + new credit card tests). + +- [ ] **Step 11: Commit** + +```bash +git add packages/cli/src/commands/billing/__tests__/top-up.test.ts +git commit -m "test(cli): add credit card flow tests for billing top-up" +``` + +--- + +## Task 6: Final verification + +- [ ] **Step 1: Run full SDK type check** + +Run: `npx tsc --noEmit -p packages/sdk/tsconfig.json` +Expected: Clean exit. + +- [ ] **Step 2: Run full CLI type check** + +Run: `npx tsc --noEmit -p packages/cli/tsconfig.json` +Expected: Clean exit. + +- [ ] **Step 3: Run all billing tests** + +Run: `npx vitest run packages/cli/src/commands/billing/__tests__/` +Expected: All tests pass. + +- [ ] **Step 4: Commit any remaining fixes if needed** diff --git a/docs/superpowers/specs/2026-04-23-top-up-credit-card-design.md b/docs/superpowers/specs/2026-04-23-top-up-credit-card-design.md new file mode 100644 index 00000000..0df449d7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-top-up-credit-card-design.md @@ -0,0 +1,211 @@ +# Top-Up Credit Card Support + +Add credit card purchasing to `ecloud billing top-up` alongside the existing USDC on-chain flow. + +## Motivation + +We're moving to a credit-based burndown system. Users need to purchase credits, and not everyone wants to use USDC on-chain. Adding credit card support via Stripe lets users top up with a familiar payment method. + +## API Routes + +Both routes live on the billing API server (`ECLOUD_BILLING_API_URL`), use the same EIP-712 signature auth as existing routes (`Authorization: Bearer `, `X-Account`, `X-Expiry`). + +### GET /v1/payment-methods + +Returns saved payment methods for the authenticated wallet. + +Request: no body, auth required. + +Response: +```json +{ + "paymentMethods": [ + { + "id": "029641fc-3e5c-11f1-986c-5601121cbf6d", + "stripePaymentMethodId": "pm_1ABC123...", + "createdAt": "2026-04-20T15:00:00Z" + } + ] +} +``` + +### POST /v1/credits/purchase + +Two modes depending on whether `paymentMethodId` is provided. + +**Direct charge (card on file):** + +Request: +```json +{ + "amountCents": 5000, + "paymentMethodId": "029641fc-3e5c-11f1-986c-5601121cbf6d" +} +``` + +Response: +```json +{ + "purchaseId": "a1b2c3d4-5e6f-11f1-986c-5601121cbf6d", + "amountCents": "5000" +} +``` + +**Checkout session (no card on file):** + +Request: +```json +{ + "amountCents": 5000 +} +``` + +Response: +```json +{ + "checkoutSessionId": "cs_test_abc123...", + "checkoutUrl": "https://checkout.stripe.com/c/pay/cs_test_abc123...", + "amountCents": "5000" +} +``` + +Minimum `amountCents`: 500 ($5.00). + +## Design + +### SDK: New methods on `BillingApiClient` + +File: `packages/sdk/src/client/common/utils/billingapi.ts` + +Add two methods to the existing `BillingApiClient` class: + +```typescript +async getPaymentMethods(): Promise +``` +- `GET ${billingApiServerURL}/v1/payment-methods` +- Uses `makeAuthenticatedRequest` with a dummy productId (e.g. `"compute"`) for signature generation since the auth scheme requires a product field. + +```typescript +async purchaseCredits(amountCents: number, paymentMethodId?: string): Promise +``` +- `POST ${billingApiServerURL}/v1/credits/purchase` +- Body: `{ amountCents }` or `{ amountCents, paymentMethodId }` depending on whether a payment method is provided. +- Uses `makeAuthenticatedRequest`. + +### SDK: New types + +File: `packages/sdk/src/client/common/types/index.ts` + +```typescript +export interface PaymentMethod { + id: string; + stripePaymentMethodId: string; + createdAt: string; +} + +export interface PaymentMethodsResponse { + paymentMethods: PaymentMethod[]; +} + +export interface CreditPurchaseResponse { + purchaseId?: string; + checkoutSessionId?: string; + checkoutUrl?: string; + amountCents: string; +} +``` + +`CreditPurchaseResponse` is a union-style interface: a direct charge returns `purchaseId` without checkout fields; a checkout session returns `checkoutSessionId` + `checkoutUrl` without `purchaseId`. + +### SDK: Export new methods from billing module + +File: `packages/sdk/src/client/modules/billing/index.ts` + +Expose the two new `BillingApiClient` methods through the `BillingModule` interface: + +```typescript +export interface BillingModule { + // ... existing methods ... + getPaymentMethods: () => Promise; + purchaseCredits: (amountCents: number, paymentMethodId?: string) => Promise; +} +``` + +Wire them to `billingApi.getPaymentMethods()` and `billingApi.purchaseCredits()` in `createBillingModule`. + +### CLI: Modified `top-up.ts` command + +File: `packages/cli/src/commands/billing/top-up.ts` + +#### New flag + +``` +--method usdc | card (optional, prompts if omitted) +``` + +#### Updated flow + +1. Show wallet address and current credit balance (unchanged). +2. **Payment method selection:** + - If `--method usdc` -> go to USDC path. + - If `--method card` -> go to credit card path. + - If no flag -> prompt user to choose between "USDC (on-chain)" and "Credit card". +3. **USDC path:** Unchanged from current implementation (steps 2-5 in existing code). +4. **Credit card path:** + a. Prompt for dollar amount (whole dollars, minimum $5). Skipped if `--amount` flag is provided. + b. Convert to cents: `amountCents = dollars * 100`. + c. Call `billing.getPaymentMethods()`. + d. If payment methods exist: + - Show: "Use card on file (pm_...1ABC)?" with yes/no prompt. + - If yes: call `billing.purchaseCredits(amountCents, paymentMethod.id)`. This returns `{ purchaseId, amountCents }`. Proceed to poll for credits. + - If no: call `billing.purchaseCredits(amountCents)` without payment method ID. This returns a checkout URL. Open in browser with `open`. Proceed to poll for credits. + e. If no payment methods: call `billing.purchaseCredits(amountCents)` (no payment method ID). Open checkout URL in browser. Proceed to poll for credits. +5. **Credit polling:** Same polling loop as today — poll `billing.getStatus()` until `remainingCredits` increases or timeout (3 minutes). + +#### Amount validation (credit card path) + +- Must be a whole dollar amount (integer). +- Minimum: $5 (500 cents). +- No maximum (Stripe handles limits). + +#### Non-interactive support + +For CI/scripting, all prompts can be skipped via flags: +- `--method card --amount 50` skips the method and amount prompts. +- Without a card on file, the checkout URL is printed to stdout (the `open` call will be attempted but the URL is always logged). +- With a card on file and no flag to choose it, the command will still prompt. Full non-interactive card selection is out of scope for this change. + +### CLI: Update command description and examples + +Update `static description` and `static examples` to reflect the new credit card option. + +### Tests + +File: `packages/cli/src/commands/billing/__tests__/top-up.test.ts` + +Add test cases: +- **Credit card, card on file, user accepts:** mock `getPaymentMethods` returning one card, mock `purchaseCredits` returning `{ purchaseId, amountCents }`, verify no browser open, verify credit polling. +- **Credit card, card on file, user declines (wants new card):** mock `purchaseCredits` returning `{ checkoutUrl, ... }`, verify `open` is called with checkout URL. +- **Credit card, no card on file:** mock `getPaymentMethods` returning empty array, mock `purchaseCredits` returning checkout URL, verify `open` is called. +- **`--method card --amount 50` skips prompts:** verify `select` and `input` are not called. +- **Amount below $5 minimum:** verify validation error. +- **Existing USDC tests remain unchanged.** + +Mock `billing.getPaymentMethods` and `billing.purchaseCredits` on the same `mockBilling` object used by existing tests. Mock `open` as already done in `subscribe.test.ts`. + +## Files changed + +| File | Change | +|------|--------| +| `packages/sdk/src/client/common/types/index.ts` | Add `PaymentMethod`, `PaymentMethodsResponse`, `CreditPurchaseResponse` | +| `packages/sdk/src/client/common/utils/billingapi.ts` | Add `getPaymentMethods()`, `purchaseCredits()` | +| `packages/sdk/src/client/modules/billing/index.ts` | Expose new methods on `BillingModule` | +| `packages/cli/src/commands/billing/top-up.ts` | Add `--method` flag, credit card flow, method selection prompt | +| `packages/cli/src/commands/billing/__tests__/top-up.test.ts` | Add credit card test cases | + +## Out of scope + +- Listing/managing saved payment methods (separate command later). +- Deleting payment methods. +- Full non-interactive card selection (auto-picking a saved card without prompting). +- Changing the subscribe command flow. From e582fceed2204c43687d097cc4086d51f4f2ba3c Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Mon, 4 May 2026 21:20:58 -0500 Subject: [PATCH 8/9] feat: add list-cards subcommand --- .../commands/billing/__tests__/top-up.test.ts | 24 +++++++++---- .../cli/src/commands/billing/list-cards.ts | 36 +++++++++++++++++++ packages/cli/src/commands/billing/top-up.ts | 26 +++++++++----- packages/sdk/src/client/common/types/index.ts | 2 ++ .../sdk/src/client/common/utils/billingapi.ts | 3 ++ 5 files changed, 75 insertions(+), 16 deletions(-) create mode 100644 packages/cli/src/commands/billing/list-cards.ts diff --git a/packages/cli/src/commands/billing/__tests__/top-up.test.ts b/packages/cli/src/commands/billing/__tests__/top-up.test.ts index 9c38fdaf..cdb4fcc8 100644 --- a/packages/cli/src/commands/billing/__tests__/top-up.test.ts +++ b/packages/cli/src/commands/billing/__tests__/top-up.test.ts @@ -11,7 +11,6 @@ vi.mock("../../../telemetry", () => ({ vi.mock("@inquirer/prompts", () => ({ input: vi.fn(), select: vi.fn(), - confirm: vi.fn(), })); vi.mock("open", () => ({ @@ -20,7 +19,7 @@ vi.mock("open", () => ({ import BillingTopUp from "../top-up"; import { createBillingClient } from "../../../client"; -import { input, select, confirm } from "@inquirer/prompts"; +import { input, select } from "@inquirer/prompts"; const WALLET_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"; const TX_HASH = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; @@ -227,7 +226,7 @@ describe("ecloud billing top-up", () => { // ── Credit Card Tests ── - it("credit card: charges existing card on file", async () => { + it("credit card: charges selected card on file", async () => { mockBilling.getStatus .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 35.0 }); @@ -236,15 +235,24 @@ describe("ecloud billing top-up", () => { { id: "029641fc-3e5c-11f1-986c-5601121cbf6d", stripePaymentMethodId: "pm_1ABC1234", + brand: "visa", + last4: "1234", createdAt: "2026-04-20T15:00:00Z", }, + { + id: "139752fd-4e6d-22f2-a97d-6712232dcg7e", + stripePaymentMethodId: "pm_2DEF5678", + brand: "mastercard", + last4: "5678", + createdAt: "2026-04-21T10:00:00Z", + }, ], }); mockBilling.purchaseCredits.mockResolvedValue({ purchaseId: "a1b2c3d4", amountCents: "2500", }); - (confirm as unknown as ReturnType).mockResolvedValue(true); + (select as unknown as ReturnType).mockResolvedValue("029641fc-3e5c-11f1-986c-5601121cbf6d"); const cmd = createCommand({ amount: "25", method: "card" }); const promise = cmd.run(); @@ -259,7 +267,7 @@ describe("ecloud billing top-up", () => { expect(fullOutput).toContain("Credits received"); }); - it("credit card: opens checkout when user declines existing card", async () => { + it("credit card: opens checkout when user selects add new card", async () => { const openMock = (await import("open")).default as ReturnType; mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0 }); mockBilling.getPaymentMethods.mockResolvedValue({ @@ -267,6 +275,8 @@ describe("ecloud billing top-up", () => { { id: "029641fc-3e5c-11f1-986c-5601121cbf6d", stripePaymentMethodId: "pm_1ABC1234", + brand: "visa", + last4: "1234", createdAt: "2026-04-20T15:00:00Z", }, ], @@ -276,7 +286,7 @@ describe("ecloud billing top-up", () => { checkoutUrl: "https://checkout.stripe.com/test", amountCents: "2500", }); - (confirm as unknown as ReturnType).mockResolvedValue(false); + (select as unknown as ReturnType).mockResolvedValue("new"); const cmd = createCommand({ amount: "25", method: "card" }); const promise = cmd.run(); @@ -305,7 +315,7 @@ describe("ecloud billing top-up", () => { await promise; const fullOutput = logOutput.join("\n"); - expect(confirm).not.toHaveBeenCalled(); + expect(select).not.toHaveBeenCalled(); expect(mockBilling.purchaseCredits).toHaveBeenCalledWith(5000, undefined); expect(openMock).toHaveBeenCalledWith("https://checkout.stripe.com/test"); expect(fullOutput).toContain("https://checkout.stripe.com/test"); diff --git a/packages/cli/src/commands/billing/list-cards.ts b/packages/cli/src/commands/billing/list-cards.ts new file mode 100644 index 00000000..1e0fafb6 --- /dev/null +++ b/packages/cli/src/commands/billing/list-cards.ts @@ -0,0 +1,36 @@ +import { Command } from "@oclif/core"; +import { createBillingClient } from "../../client"; +import { commonFlags } from "../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../telemetry"; + +export default class BillingListCards extends Command { + static description = "List credit cards on file"; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(BillingListCards); + const billing = await createBillingClient(flags); + + const { paymentMethods } = await billing.getPaymentMethods(); + + if (paymentMethods.length === 0) { + this.log(`\n ${chalk.gray("No cards on file.")}`); + this.log(` Run ${chalk.cyan("ecloud billing top-up --method card")} to add one.\n`); + return; + } + + this.log(`\n${chalk.bold("Cards on file:")}`); + for (const card of paymentMethods) { + const brand = card.brand.charAt(0).toUpperCase() + card.brand.slice(1); + const added = new Date(card.createdAt).toLocaleDateString(); + this.log(` • ${brand} ending in ${chalk.bold(card.last4)} ${chalk.gray(`added ${added}`)}`); + } + this.log(); + }); + } +} diff --git a/packages/cli/src/commands/billing/top-up.ts b/packages/cli/src/commands/billing/top-up.ts index 43ca7c48..2708dbae 100644 --- a/packages/cli/src/commands/billing/top-up.ts +++ b/packages/cli/src/commands/billing/top-up.ts @@ -17,7 +17,7 @@ import { createBillingClient } from "../../client"; import { commonFlags } from "../../flags"; import { type Address, formatUnits } from "viem"; import chalk from "chalk"; -import { input, select, confirm } from "@inquirer/prompts"; +import { input, select } from "@inquirer/prompts"; import open from "open"; import { withTelemetry } from "../../telemetry"; @@ -195,18 +195,22 @@ export default class BillingTopUp extends Command { // Check for existing payment methods const { paymentMethods } = await billing.getPaymentMethods(); - let useExistingCard = false; let paymentMethodId: string | undefined; if (paymentMethods.length > 0) { - const card = paymentMethods[0]; - const lastFour = card.stripePaymentMethodId.slice(-4); - useExistingCard = await confirm({ - message: `Use card on file (ending in ${lastFour})?`, - default: true, + const choices = paymentMethods.map((card) => ({ + value: card.id, + name: `${card.brand.charAt(0).toUpperCase() + card.brand.slice(1)} ending in ${card.last4}`, + })); + choices.push({ value: "new", name: "Add a new card" }); + + const selection = await select({ + message: "Which card would you like to use?", + choices, }); - if (useExistingCard) { - paymentMethodId = card.id; + + if (selection !== "new") { + paymentMethodId = selection; } } @@ -218,6 +222,10 @@ export default class BillingTopUp extends Command { this.log(`\n ${chalk.cyan(result.checkoutUrl)}`); this.log(chalk.gray(" Opening checkout in browser...")); await open(result.checkoutUrl); + } else if (result.checkoutSessionId) { + this.error( + "Checkout session created but no URL was returned. Please contact support.", + ); } else { this.log(` ${chalk.green("✓")} Payment submitted`); } diff --git a/packages/sdk/src/client/common/types/index.ts b/packages/sdk/src/client/common/types/index.ts index 1a0fda0d..f6d10340 100644 --- a/packages/sdk/src/client/common/types/index.ts +++ b/packages/sdk/src/client/common/types/index.ts @@ -422,6 +422,8 @@ export interface SubscriptionOpts { export interface PaymentMethod { id: string; stripePaymentMethodId: string; + brand: string; + last4: string; createdAt: string; } diff --git a/packages/sdk/src/client/common/utils/billingapi.ts b/packages/sdk/src/client/common/utils/billingapi.ts index 46285578..3edd3b3a 100644 --- a/packages/sdk/src/client/common/utils/billingapi.ts +++ b/packages/sdk/src/client/common/utils/billingapi.ts @@ -216,6 +216,9 @@ export class BillingApiClient { ): Promise<{ json: () => Promise; text: () => Promise }> { if (this.options.verbose) { console.debug(`[BillingAPI] ${method} ${url}`); + if (body) { + console.debug(`[BillingAPI] Payload:`, JSON.stringify(body, null, 2)); + } } const resp = this.useSession ? await this.makeSessionAuthenticatedRequest(url, method, body) From b938c93045cf6d1b50dcd25c8464e3fc78c696b5 Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Tue, 5 May 2026 10:53:28 -0500 Subject: [PATCH 9/9] fix: dont show "credits received" message when checkout form times out or fails --- .../commands/billing/__tests__/top-up.test.ts | 21 +++++++------------ packages/cli/src/commands/billing/top-up.ts | 15 ++++++------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/commands/billing/__tests__/top-up.test.ts b/packages/cli/src/commands/billing/__tests__/top-up.test.ts index cdb4fcc8..44014b39 100644 --- a/packages/cli/src/commands/billing/__tests__/top-up.test.ts +++ b/packages/cli/src/commands/billing/__tests__/top-up.test.ts @@ -173,7 +173,7 @@ describe("ecloud billing top-up", () => { }); }); - it("billing API poll timeout: shows timeout message", async () => { + it("billing API poll timeout: throws timeout error", async () => { setupOnChainState(); mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); mockBilling.getStatus.mockResolvedValue({ @@ -184,11 +184,7 @@ describe("ecloud billing top-up", () => { const cmd = createCommand({ amount: "50", method: "usdc" }); const promise = cmd.run(); await vi.advanceTimersByTimeAsync(200_000); - await promise; - const fullOutput = logOutput.join("\n"); - - expect(fullOutput).toContain("Credits haven't appeared yet"); - expect(fullOutput).toContain("ecloud billing status"); + await expect(promise).rejects.toThrow("Timed out waiting for credits to appear"); }); it("uses --amount flag when provided (skips prompt)", async () => { @@ -208,7 +204,7 @@ describe("ecloud billing top-up", () => { expect(input).not.toHaveBeenCalled(); }); - it("does not fail if status check errors", async () => { + it("does not fail if status check errors during polling", async () => { setupOnChainState(); mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); mockBilling.getStatus.mockRejectedValue(new Error("API unavailable")); @@ -216,12 +212,11 @@ describe("ecloud billing top-up", () => { const cmd = createCommand({ amount: "50", method: "usdc" }); const promise = cmd.run(); await vi.advanceTimersByTimeAsync(200_000); - await promise; + await expect(promise).rejects.toThrow("Timed out waiting for credits to appear"); const fullOutput = logOutput.join("\n"); expect(fullOutput).toContain("Purchasing"); expect(fullOutput).toContain("Transaction confirmed"); - expect(fullOutput).toContain("Credits haven't appeared yet"); }); // ── Credit Card Tests ── @@ -290,8 +285,8 @@ describe("ecloud billing top-up", () => { const cmd = createCommand({ amount: "25", method: "card" }); const promise = cmd.run(); - await vi.advanceTimersByTimeAsync(200_000); - await promise; + await vi.advanceTimersByTimeAsync(310_000); + await expect(promise).rejects.toThrow("Timed out waiting for credits to appear"); const fullOutput = logOutput.join("\n"); expect(mockBilling.purchaseCredits).toHaveBeenCalledWith(2500, undefined); @@ -311,8 +306,8 @@ describe("ecloud billing top-up", () => { const cmd = createCommand({ amount: "50", method: "card" }); const promise = cmd.run(); - await vi.advanceTimersByTimeAsync(200_000); - await promise; + await vi.advanceTimersByTimeAsync(310_000); + await expect(promise).rejects.toThrow("Timed out waiting for credits to appear"); const fullOutput = logOutput.join("\n"); expect(select).not.toHaveBeenCalled(); diff --git a/packages/cli/src/commands/billing/top-up.ts b/packages/cli/src/commands/billing/top-up.ts index 2708dbae..0c52019c 100644 --- a/packages/cli/src/commands/billing/top-up.ts +++ b/packages/cli/src/commands/billing/top-up.ts @@ -22,7 +22,8 @@ import open from "open"; import { withTelemetry } from "../../telemetry"; const POLL_INTERVAL_MS = 5_000; -const POLL_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes +const POLL_TIMEOUT_USDC_MS = 3 * 60 * 1000; // 3 minutes +const POLL_TIMEOUT_CARD_MS = 5 * 60 * 1000; // 5 minutes export default class BillingTopUp extends Command { static description = "Purchase EigenCompute credits with USDC or credit card"; @@ -162,7 +163,7 @@ export default class BillingTopUp extends Command { }); this.log(` ${chalk.green("✓")} Transaction confirmed: ${txHash}`); - await this.pollForCredits(billing, flags, baselineTotal, amountFloat); + await this.pollForCredits(billing, flags, baselineTotal, amountFloat, POLL_TIMEOUT_USDC_MS); } private async handleCard( @@ -230,7 +231,7 @@ export default class BillingTopUp extends Command { this.log(` ${chalk.green("✓")} Payment submitted`); } - await this.pollForCredits(billing, flags, baselineTotal, dollars); + await this.pollForCredits(billing, flags, baselineTotal, dollars, POLL_TIMEOUT_CARD_MS); } private async pollForCredits( @@ -238,10 +239,11 @@ export default class BillingTopUp extends Command { flags: Record, baselineTotal: number | undefined, amountPurchased: number, + timeoutMs: number, ) { this.log(chalk.gray("\n Waiting for credits to appear...")); const startTime = Date.now(); - while (Date.now() - startTime < POLL_TIMEOUT_MS) { + while (Date.now() - startTime < timeoutMs) { await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); try { const status = await billing.getStatus({ @@ -270,9 +272,8 @@ export default class BillingTopUp extends Command { } } - this.log( - `\n ${chalk.yellow("⚠")} Credits haven't appeared yet. This can take a few minutes.`, + this.error( + `Timed out waiting for credits to appear. Check your balance with: ecloud billing status`, ); - this.log(` ${chalk.gray("Check your balance:")} ecloud billing status\n`); } }