Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8756984
fix: correctly show credits remaining from new API
seanmcgary Apr 22, 2026
7ba8d1a
feat(sdk): add PaymentMethod and CreditPurchaseResponse types
seanmcgary Apr 23, 2026
59d4812
feat(sdk): add getPaymentMethods and purchaseCredits to BillingApiClient
seanmcgary Apr 23, 2026
89fc8b3
feat(sdk): expose getPaymentMethods and purchaseCredits on BillingModule
seanmcgary Apr 23, 2026
72fb9e3
feat(cli): add credit card payment flow to billing top-up
seanmcgary Apr 23, 2026
4407666
test(cli): add credit card flow tests for billing top-up
seanmcgary Apr 23, 2026
49f6f2b
execution plan
seanmcgary Apr 29, 2026
562f22a
feat: add list-cards subcommand
seanmcgary May 5, 2026
8667796
feat: add Base Sepolia chain config for USDC credit purchases
seanmcgary May 6, 2026
72ee53d
feat: add BillingChain type and hasBaseSupport to billing module inte…
seanmcgary May 6, 2026
af72ae9
feat: implement chain-aware getTopUpInfo and topUp for Base support
seanmcgary May 6, 2026
ed1d78d
feat: export BillingChain type and BASE_SEPOLIA_CHAIN_ID from SDK
seanmcgary May 6, 2026
68fcd6b
feat: add chain selection prompt for USDC top-up (Ethereum/Base)
seanmcgary May 6, 2026
f4e61a0
test: add chain selection tests for Base USDC top-up
seanmcgary May 6, 2026
1f55c5b
doc: execution plan
seanmcgary May 6, 2026
34e33a9
feat(sdk): add admin and coupon API methods to BillingApiClient
seanmcgary May 18, 2026
c2cebc9
feat(sdk): add AdminModule and redeemCoupon to BillingModule
seanmcgary May 18, 2026
2ca4971
feat(cli): add billing redeem-coupon command
seanmcgary May 18, 2026
b937c51
feat(cli): add admin coupons commands (create, list, get, deactivate,…
seanmcgary May 18, 2026
d44558f
feat(cli): add admin admins commands (add, remove, list)
seanmcgary May 18, 2026
3c10895
feat(cli): register admin topics in oclif config
seanmcgary May 18, 2026
bec561a
chore: execution plan
seanmcgary May 18, 2026
7451666
feat: admin subcommands, coupon redemption (#156)
seanmcgary May 20, 2026
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
770 changes: 770 additions & 0 deletions docs/superpowers/plans/2026-04-23-top-up-credit-card.md

Large diffs are not rendered by default.

670 changes: 670 additions & 0 deletions docs/superpowers/plans/2026-05-06-base-chain-usdc-topup.md

Large diffs are not rendered by default.

934 changes: 934 additions & 0 deletions docs/superpowers/plans/2026-05-17-admin-and-coupon-commands.md

Large diffs are not rendered by default.

211 changes: 211 additions & 0 deletions docs/superpowers/specs/2026-04-23-top-up-credit-card-design.md
Original file line number Diff line number Diff line change
@@ -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 <sig>`, `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<PaymentMethodsResponse>
```
- `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<CreditPurchaseResponse>
```
- `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<PaymentMethodsResponse>;
purchaseCredits: (amountCents: number, paymentMethodId?: string) => Promise<CreditPurchaseResponse>;
}
```

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.
9 changes: 9 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@
"compute:env": {
"hidden": true,
"description": "Manage deployment environment [alias: env]"
},
"admin": {
"description": "Admin operations (requires admin privileges)"
},
"admin:coupons": {
"description": "Manage coupons"
},
"admin:admins": {
"description": "Manage admin users"
}
}
},
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
createComputeModule,
createBillingModule,
createBuildModule,
createAdminModule,
getEnvironmentConfig,
requirePrivateKey,
} from "@layr-labs/ecloud-sdk";
Expand Down Expand Up @@ -67,6 +68,7 @@ export async function createBillingClient(flags: CommonFlags) {
publicClient,
environment,
skipTelemetry: true,
privateKey: privateKey as Hex,
});
}

Expand Down Expand Up @@ -98,3 +100,31 @@ export async function createBuildClient(flags: CommonFlags) {
skipTelemetry: true, // CLI already has telemetry, skip SDK telemetry
});
}

export async function createAdminClient(flags: CommonFlags) {
flags = await validateCommonFlags(flags);

const environment = flags.environment;
const environmentConfig = getEnvironmentConfig(environment);
const rpcUrl = flags["rpc-url"] || environmentConfig.billingRPCURL || environmentConfig.defaultRPCURL;
const { key: privateKey, source } = await requirePrivateKey({
privateKey: flags["private-key"],
});

if (flags.verbose) {
console.log(`Using private key from: ${source}`);
}

const { walletClient, publicClient } = createViemClients({
privateKey: privateKey as Hex,
rpcUrl,
environment,
});

return createAdminModule({
verbose: flags.verbose,
walletClient,
publicClient,
environment,
});
}
34 changes: 34 additions & 0 deletions packages/cli/src/commands/admin/admins/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Args, Command } from "@oclif/core";
import { createAdminClient } from "../../../client";
import { commonFlags } from "../../../flags";
import chalk from "chalk";
import { withTelemetry } from "../../../telemetry";

export default class AdminAdminsAdd extends Command {
static description = "Add a new admin";

static examples = [
"<%= config.bin %> admin admins add 0x...",
];

static args = {
address: Args.string({ description: "Wallet address to grant admin", required: true }),
};

static flags = {
...commonFlags,
};

async run() {
return withTelemetry(this, async () => {
const { args, flags } = await this.parse(AdminAdminsAdd);
const admin = await createAdminClient(flags);

const { admin: newAdmin } = await admin.addAdmin(args.address);

this.log(`\n ${chalk.green("✓")} Admin added`);
this.log(` Address: ${chalk.cyan(newAdmin.address)}`);
this.log(` ID: ${newAdmin.id}\n`);
});
}
}
37 changes: 37 additions & 0 deletions packages/cli/src/commands/admin/admins/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Command } from "@oclif/core";
import { createAdminClient } from "../../../client";
import { commonFlags } from "../../../flags";
import chalk from "chalk";
import { withTelemetry } from "../../../telemetry";

export default class AdminAdminsList extends Command {
static description = "List all admins";

static examples = [
"<%= config.bin %> admin admins list",
];

static flags = {
...commonFlags,
};

async run() {
return withTelemetry(this, async () => {
const { flags } = await this.parse(AdminAdminsList);
const admin = await createAdminClient(flags);

const { admins } = await admin.listAdmins();

if (admins.length === 0) {
this.log("\n No admins found.\n");
return;
}

this.log(`\n${chalk.bold("Admins")} (${admins.length}):\n`);
for (const a of admins) {
this.log(` ${chalk.cyan(a.address)} ${chalk.gray(a.createdAt)}`);
}
this.log();
});
}
}
32 changes: 32 additions & 0 deletions packages/cli/src/commands/admin/admins/remove.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Args, Command } from "@oclif/core";
import { createAdminClient } from "../../../client";
import { commonFlags } from "../../../flags";
import chalk from "chalk";
import { withTelemetry } from "../../../telemetry";

export default class AdminAdminsRemove extends Command {
static description = "Remove an admin";

static examples = [
"<%= config.bin %> admin admins remove 0x...",
];

static args = {
address: Args.string({ description: "Wallet address to remove from admins", required: true }),
};

static flags = {
...commonFlags,
};

async run() {
return withTelemetry(this, async () => {
const { args, flags } = await this.parse(AdminAdminsRemove);
const admin = await createAdminClient(flags);

await admin.removeAdmin(args.address);

this.log(`\n ${chalk.green("✓")} Admin ${chalk.cyan(args.address)} removed.\n`);
});
}
}
Loading
Loading