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..16a58e85 100644 --- a/packages/cli/src/commands/billing/__tests__/top-up.test.ts +++ b/packages/cli/src/commands/billing/__tests__/top-up.test.ts @@ -26,6 +26,7 @@ describe("ecloud billing top-up", () => { getStatus: ReturnType; getTopUpInfo: ReturnType; topUp: ReturnType; + redeemCode: ReturnType; }; beforeEach(() => { @@ -37,6 +38,7 @@ describe("ecloud billing top-up", () => { getStatus: vi.fn(), getTopUpInfo: vi.fn(), topUp: vi.fn(), + redeemCode: vi.fn(), }; (createBillingClient as ReturnType).mockResolvedValue(mockBilling); @@ -227,4 +229,35 @@ describe("ecloud billing top-up", () => { // Will timeout on polling since status always errors expect(fullOutput).toContain("Credits haven't appeared yet"); }); + + it("--code: happy path prints granted amount and new balance", async () => { + const expiresAt = Math.floor(Date.now() / 1000) + 90 * 24 * 3600; + mockBilling.redeemCode.mockResolvedValue({ + code: "LAUNCH50", + grantedAmount: 50, + remainingCredits: 55, + expiresAt, + }); + + const cmd = createCommand({ code: "LAUNCH50" }); + await cmd.run(); + const fullOutput = logOutput.join("\n"); + + expect(mockBilling.redeemCode).toHaveBeenCalledWith({ code: "LAUNCH50", productId: "compute" }); + expect(fullOutput).toContain("Redeem promotion code"); + expect(fullOutput).toContain("LAUNCH50"); + expect(fullOutput).toContain("$50.00"); + expect(fullOutput).toContain("$55.00"); + // USDC flow must not run + expect(mockBilling.getTopUpInfo).not.toHaveBeenCalled(); + expect(mockBilling.topUp).not.toHaveBeenCalled(); + }); + + it("--code: surfaces friendly error on 404", async () => { + mockBilling.redeemCode.mockRejectedValue(new Error("BillingAPI request failed: 404 Error - Promotion code not found or inactive")); + + const cmd = createCommand({ code: "BADCODE" }); + await expect(cmd.run()).rejects.toThrow(/not valid|inactive|already redeemed/i); + expect(mockBilling.topUp).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/commands/billing/top-up.ts b/packages/cli/src/commands/billing/top-up.ts index 7637507b..b8d3961a 100644 --- a/packages/cli/src/commands/billing/top-up.ts +++ b/packages/cli/src/commands/billing/top-up.ts @@ -23,7 +23,7 @@ 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 redeem a promo code"; static flags = { ...commonFlags, @@ -35,6 +35,10 @@ export default class BillingTopUp extends Command { required: false, description: "Target account address for purchaseCreditsFor (defaults to your wallet)", }), + code: Flags.string({ + required: false, + description: "Redeem a promotion code for promotional credits (skips USDC flow)", + }), product: Flags.string({ required: false, description: "Product ID", @@ -51,6 +55,10 @@ export default class BillingTopUp extends Command { // Create billing client const billing = await createBillingClient(flags); + if (flags.code) { + return this.redeemCode(billing, flags.product as "compute", flags.code); + } + const walletAddress = billing.address; const targetAccount = (flags.account as Address) ?? walletAddress; @@ -176,4 +184,39 @@ export default class BillingTopUp extends Command { this.log(` ${chalk.gray("Check your balance:")} ecloud billing status\n`); }); } + + private async redeemCode( + billing: Awaited>, + productId: "compute", + code: string, + ) { + this.log(`\n${chalk.bold("Redeem promotion code")}`); + this.log(`${chalk.gray("─".repeat(45))}`); + this.log(`\n ${chalk.bold("Wallet:")} ${billing.address}`); + this.log(` ${chalk.bold("Code:")} ${code}`); + + try { + const result = await billing.redeemCode({ code, productId }); + this.log( + `\n ${chalk.green("✓")} Redeemed ${chalk.bold(code)}: ${chalk.cyan(`$${result.grantedAmount.toFixed(2)}`)} in credits`, + ); + if (result.remainingCredits !== undefined) { + this.log(` ${chalk.bold("Balance:")} ${chalk.cyan(`$${result.remainingCredits.toFixed(2)}`)}`); + } + if (result.expiresAt) { + const expiry = new Date(result.expiresAt * 1000).toLocaleDateString(); + this.log(` ${chalk.gray(`Credits expire: ${expiry}`)}`); + } + this.log(); + } catch (err: any) { + const msg = err?.message ?? String(err); + if (msg.includes("404") || msg.toLowerCase().includes("not found")) { + this.error(`Code "${code}" is not valid, inactive, or already redeemed.`); + } + if (msg.includes("422")) { + this.error(`Code "${code}" is not a fixed-amount credit code (percent-off codes are not supported).`); + } + this.error(`Failed to redeem code: ${msg}`); + } + } } diff --git a/packages/sdk/src/client/common/types/index.ts b/packages/sdk/src/client/common/types/index.ts index c42a8d71..3e56ab07 100644 --- a/packages/sdk/src/client/common/types/index.ts +++ b/packages/sdk/src/client/common/types/index.ts @@ -395,6 +395,17 @@ export interface NoActiveSubscriptionResponse { export type CancelResponse = CancelSuccessResponse | NoActiveSubscriptionResponse; +export interface RedeemCodeRequest { + code: string; +} + +export interface RedeemCodeResponse { + code: string; + grantedAmount: number; + remainingCredits?: number; + expiresAt?: number; +} + export interface ProductSubscriptionResponse { productId: ProductID; subscriptionStatus: SubscriptionStatus; diff --git a/packages/sdk/src/client/common/utils/billingapi.ts b/packages/sdk/src/client/common/utils/billingapi.ts index 3dbbfc37..b635e9cc 100644 --- a/packages/sdk/src/client/common/utils/billingapi.ts +++ b/packages/sdk/src/client/common/utils/billingapi.ts @@ -18,6 +18,7 @@ import { CreateSubscriptionResponse, GetSubscriptionOptions, ProductSubscriptionResponse, + RedeemCodeResponse, } from "../types"; import { calculateBillingAuthSignature } from "./auth"; import { BillingEnvironmentConfig } from "../types"; @@ -176,6 +177,15 @@ export class BillingApiClient { await this.makeAuthenticatedRequest(endpoint, "DELETE", productId); } + async redeemCode( + code: string, + productId: ProductID = "compute", + ): Promise { + const endpoint = `${this.config.billingApiServerURL}/products/${productId}/redeem`; + const resp = await this.makeAuthenticatedRequest(endpoint, "POST", productId, { code }); + return resp.json(); + } + // ========================================================================== // Internal Methods // ========================================================================== diff --git a/packages/sdk/src/client/modules/billing/index.ts b/packages/sdk/src/client/modules/billing/index.ts index d68f477d..dcf531bc 100644 --- a/packages/sdk/src/client/modules/billing/index.ts +++ b/packages/sdk/src/client/modules/billing/index.ts @@ -23,6 +23,7 @@ import type { SubscribeResponse, CancelResponse, ProductSubscriptionResponse, + RedeemCodeResponse, } from "../../common/types"; export interface TopUpOpts { @@ -44,6 +45,11 @@ export interface TopUpInfo { currentAllowance: bigint; } +export interface RedeemCodeOpts { + code: string; + productId?: ProductID; +} + export interface BillingModule { address: Address; subscribe: (opts?: SubscriptionOpts) => Promise; @@ -53,6 +59,8 @@ export interface BillingModule { getTopUpInfo: () => Promise; /** Purchase credits with USDC on-chain */ topUp: (opts: TopUpOpts) => Promise; + /** Redeem a promotion code for promotional credits */ + redeemCode: (opts: RedeemCodeOpts) => Promise; } export interface BillingModuleConfig { @@ -281,6 +289,21 @@ export function createBillingModule(config: BillingModuleConfig): BillingModule }, ); }, + + async redeemCode(opts) { + return withSDKTelemetry( + { + functionName: "redeemCode", + skipTelemetry, + properties: { productId: opts.productId || "compute" }, + }, + async () => { + const productId: ProductID = opts.productId || "compute"; + logger.debug(`Redeeming code for ${productId}...`); + return billingApi.redeemCode(opts.code, productId); + }, + ); + }, }; return module;