Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions packages/cli/src/commands/billing/__tests__/top-up.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe("ecloud billing top-up", () => {
getStatus: ReturnType<typeof vi.fn>;
getTopUpInfo: ReturnType<typeof vi.fn>;
topUp: ReturnType<typeof vi.fn>;
redeemCode: ReturnType<typeof vi.fn>;
};

beforeEach(() => {
Expand All @@ -37,6 +38,7 @@ describe("ecloud billing top-up", () => {
getStatus: vi.fn(),
getTopUpInfo: vi.fn(),
topUp: vi.fn(),
redeemCode: vi.fn(),
};
(createBillingClient as ReturnType<typeof vi.fn>).mockResolvedValue(mockBilling);

Expand Down Expand Up @@ -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();
});
});
45 changes: 44 additions & 1 deletion packages/cli/src/commands/billing/top-up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -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;

Expand Down Expand Up @@ -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<ReturnType<typeof createBillingClient>>,
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}`);
}
}
}
11 changes: 11 additions & 0 deletions packages/sdk/src/client/common/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions packages/sdk/src/client/common/utils/billingapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
CreateSubscriptionResponse,
GetSubscriptionOptions,
ProductSubscriptionResponse,
RedeemCodeResponse,
} from "../types";
import { calculateBillingAuthSignature } from "./auth";
import { BillingEnvironmentConfig } from "../types";
Expand Down Expand Up @@ -176,6 +177,15 @@ export class BillingApiClient {
await this.makeAuthenticatedRequest(endpoint, "DELETE", productId);
}

async redeemCode(
code: string,
productId: ProductID = "compute",
): Promise<RedeemCodeResponse> {
const endpoint = `${this.config.billingApiServerURL}/products/${productId}/redeem`;
const resp = await this.makeAuthenticatedRequest(endpoint, "POST", productId, { code });
return resp.json();
}

// ==========================================================================
// Internal Methods
// ==========================================================================
Expand Down
23 changes: 23 additions & 0 deletions packages/sdk/src/client/modules/billing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
SubscribeResponse,
CancelResponse,
ProductSubscriptionResponse,
RedeemCodeResponse,
} from "../../common/types";

export interface TopUpOpts {
Expand All @@ -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<SubscribeResponse>;
Expand All @@ -53,6 +59,8 @@ export interface BillingModule {
getTopUpInfo: () => Promise<TopUpInfo>;
/** Purchase credits with USDC on-chain */
topUp: (opts: TopUpOpts) => Promise<TopUpResult>;
/** Redeem a promotion code for promotional credits */
redeemCode: (opts: RedeemCodeOpts) => Promise<RedeemCodeResponse>;
}

export interface BillingModuleConfig {
Expand Down Expand Up @@ -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;
Expand Down
Loading