Skip to content

Commit b80a0dd

Browse files
volinskeyclaude
andcommitted
feat: email billing accounts + Stripe tier checkout + email packs
5 new MCP tools + CLI billing command + OpenClaw shim for run402 email billing feature (phase 17 of email-billing-accounts). MCP tools: - create_email_billing_account(email) — create Stripe-only billing account - link_wallet_to_account(billing_account_id, wallet) — hybrid Stripe+x402 - tier_checkout(tier, email|wallet) — Stripe tier subscription - buy_email_pack(email|wallet) — $5 pack (10k emails, never expire) - set_auto_recharge(billing_account_id, enabled, threshold?) — auto refill CLI: - run402 billing create-email <email> - run402 billing link-wallet <account_id> <wallet> - run402 billing tier-checkout <tier> [--email|--wallet] - run402 billing buy-pack [--email|--wallet] - run402 billing auto-recharge <account_id> <on|off> [--threshold <n>] - run402 billing balance <identifier> - run402 billing history <identifier> [--limit <n>] OpenClaw: openclaw/scripts/billing.mjs re-exports from cli/lib/ Tests: 13/13 sync tests passing. 21/21 SKILL tests passing. Tool docs updated in SKILL.md and README.md. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ea8b127 commit b80a0dd

12 files changed

Lines changed: 477 additions & 2 deletions

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ npx run402-mcp
9090
| `register_sender_domain` | Register a custom email sending domain (DKIM verification). |
9191
| `sender_domain_status` | Check sender domain verification status. |
9292
| `remove_sender_domain` | Remove a custom sender domain. |
93+
| `create_email_billing_account` | Create a Stripe-only billing account by email (no wallet required). |
94+
| `link_wallet_to_account` | Link a wallet to an email account for hybrid Stripe + x402 access. |
95+
| `tier_checkout` | Subscribe/renew/upgrade to a tier via Stripe (alternative to x402). |
96+
| `buy_email_pack` | Buy a $5 email pack (10,000 emails, never expire). |
97+
| `set_auto_recharge` | Enable/disable auto-recharge for email packs when credits run low. |
9398

9499
## Client Configuration
95100

SKILL.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,73 @@ Remove a project's custom sender domain. Email reverts to sending from `mail.run
443443
remove_sender_domain(project_id: "prj_...")
444444
```
445445

446+
### create_email_billing_account
447+
448+
Create a Stripe-only billing account identified by email (no wallet required). Sends a verification email. Idempotent — duplicate emails return the existing account.
449+
450+
**Parameters:**
451+
- `email` (required) — Email address
452+
453+
**Example:**
454+
```
455+
create_email_billing_account(email: "user@example.com")
456+
```
457+
458+
### link_wallet_to_account
459+
460+
Link a wallet to an existing email billing account for hybrid Stripe + x402 access. Fails if the wallet is already linked elsewhere.
461+
462+
**Parameters:**
463+
- `billing_account_id` (required) — The billing account ID
464+
- `wallet` (required) — Wallet address (0x...)
465+
466+
**Example:**
467+
```
468+
link_wallet_to_account(billing_account_id: "acct_...", wallet: "0x...")
469+
```
470+
471+
### tier_checkout
472+
473+
Subscribe, renew, or upgrade a run402 tier via Stripe credit card. Alternative to x402 on-chain payment. Returns a Stripe checkout URL for the user to complete.
474+
475+
**Parameters:**
476+
- `tier` (required) — `prototype`, `hobby`, or `team`
477+
- `email` (optional) — Email address for email-based accounts
478+
- `wallet` (optional) — Wallet address for wallet-based accounts
479+
- (must provide either email or wallet)
480+
481+
**Example:**
482+
```
483+
tier_checkout(tier: "hobby", email: "user@example.com")
484+
```
485+
486+
### buy_email_pack
487+
488+
Buy a $5 email pack (10,000 emails, never expire). Pack credits activate only when the tier daily limit is exhausted AND the project has a verified custom sender domain. Returns a Stripe checkout URL.
489+
490+
**Parameters:**
491+
- `email` (optional) — Email address for email-based accounts
492+
- `wallet` (optional) — Wallet address for wallet-based accounts
493+
494+
**Example:**
495+
```
496+
buy_email_pack(email: "user@example.com")
497+
```
498+
499+
### set_auto_recharge
500+
501+
Enable or disable automatic $5 email pack repurchase when credits drop below a threshold. Requires a saved Stripe payment method. 3 consecutive failures auto-disable.
502+
503+
**Parameters:**
504+
- `billing_account_id` (required) — The billing account ID
505+
- `enabled` (required) — Boolean
506+
- `threshold` (optional) — Credit threshold to trigger (default 2000)
507+
508+
**Example:**
509+
```
510+
set_auto_recharge(billing_account_id: "acct_...", enabled: true, threshold: 2000)
511+
```
512+
446513
## Standard Workflow
447514

448515
Follow this sequence to go from zero to a working database:

cli/cli.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Commands:
3939
message Send messages to Run402 developers
4040
auth Manage project user authentication (magic link, passwords, settings)
4141
sender-domain Manage custom email sender domain (register, status, remove)
42+
billing Email billing accounts, Stripe tier checkout, email packs
4243
agent Manage agent identity (contact info)
4344
4445
Run 'run402 <command> --help' for detailed usage of each command.
@@ -171,6 +172,11 @@ switch (cmd) {
171172
await run(sub, rest);
172173
break;
173174
}
175+
case "billing": {
176+
const { run } = await import("./lib/billing.mjs");
177+
await run(sub, rest);
178+
break;
179+
}
174180
default:
175181
console.error(`Unknown command: ${cmd}\n`);
176182
console.log(HELP);

cli/lib/billing.mjs

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { API } from "./config.mjs";
2+
3+
const HELP = `run402 billing — Email billing accounts, Stripe tier checkout, email packs
4+
5+
Usage:
6+
run402 billing <subcommand> [args...]
7+
8+
Subcommands:
9+
create-email <email> Create an email billing account
10+
link-wallet <account_id> <wallet> Link a wallet to an email account
11+
tier-checkout <tier> [--email <e> | --wallet <w>] Stripe tier checkout
12+
buy-pack [--email <e> | --wallet <w>] Buy \$5 email pack (10,000 emails)
13+
auto-recharge <account_id> <on|off> [--threshold <n>]
14+
balance <identifier> Balance by email or wallet (0x...)
15+
history <identifier> [--limit <n>] Ledger history by email or wallet
16+
17+
Examples:
18+
run402 billing create-email user@example.com
19+
run402 billing tier-checkout hobby --email user@example.com
20+
run402 billing buy-pack --wallet 0x1234...
21+
run402 billing auto-recharge acct_abc on --threshold 2000
22+
run402 billing balance user@example.com
23+
`;
24+
25+
function parseFlag(args, flag) {
26+
for (let i = 0; i < args.length; i++) {
27+
if (args[i] === flag && args[i + 1]) return args[i + 1];
28+
}
29+
return null;
30+
}
31+
32+
async function createEmail(args) {
33+
const email = args[0];
34+
if (!email) {
35+
console.error(JSON.stringify({ status: "error", message: "Missing email. Usage: run402 billing create-email <email>" }));
36+
process.exit(1);
37+
}
38+
const res = await fetch(`${API}/billing/v1/accounts`, {
39+
method: "POST",
40+
headers: { "Content-Type": "application/json" },
41+
body: JSON.stringify({ email }),
42+
});
43+
const data = await res.json();
44+
if (!res.ok) { console.error(JSON.stringify({ status: "error", http: res.status, ...data })); process.exit(1); }
45+
console.log(JSON.stringify(data, null, 2));
46+
}
47+
48+
async function linkWallet(args) {
49+
const accountId = args[0];
50+
const wallet = args[1];
51+
if (!accountId || !wallet) {
52+
console.error(JSON.stringify({ status: "error", message: "Usage: run402 billing link-wallet <account_id> <wallet>" }));
53+
process.exit(1);
54+
}
55+
const res = await fetch(`${API}/billing/v1/accounts/${encodeURIComponent(accountId)}/link-wallet`, {
56+
method: "POST",
57+
headers: { "Content-Type": "application/json" },
58+
body: JSON.stringify({ wallet }),
59+
});
60+
const data = await res.json();
61+
if (!res.ok) { console.error(JSON.stringify({ status: "error", http: res.status, ...data })); process.exit(1); }
62+
console.log(JSON.stringify(data, null, 2));
63+
}
64+
65+
async function tierCheckout(args) {
66+
const tier = args[0];
67+
if (!tier) {
68+
console.error(JSON.stringify({ status: "error", message: "Usage: run402 billing tier-checkout <tier> [--email <e> | --wallet <w>]" }));
69+
process.exit(1);
70+
}
71+
const email = parseFlag(args, "--email");
72+
const wallet = parseFlag(args, "--wallet");
73+
if (!email && !wallet) {
74+
console.error(JSON.stringify({ status: "error", message: "Must provide --email or --wallet" }));
75+
process.exit(1);
76+
}
77+
const body = email ? { email } : { wallet };
78+
const res = await fetch(`${API}/billing/v1/tiers/${encodeURIComponent(tier)}/checkout`, {
79+
method: "POST",
80+
headers: { "Content-Type": "application/json" },
81+
body: JSON.stringify(body),
82+
});
83+
const data = await res.json();
84+
if (!res.ok) { console.error(JSON.stringify({ status: "error", http: res.status, ...data })); process.exit(1); }
85+
console.log(JSON.stringify(data, null, 2));
86+
}
87+
88+
async function buyPack(args) {
89+
const email = parseFlag(args, "--email");
90+
const wallet = parseFlag(args, "--wallet");
91+
if (!email && !wallet) {
92+
console.error(JSON.stringify({ status: "error", message: "Must provide --email or --wallet" }));
93+
process.exit(1);
94+
}
95+
const body = email ? { email } : { wallet };
96+
const res = await fetch(`${API}/billing/v1/email-packs/checkout`, {
97+
method: "POST",
98+
headers: { "Content-Type": "application/json" },
99+
body: JSON.stringify(body),
100+
});
101+
const data = await res.json();
102+
if (!res.ok) { console.error(JSON.stringify({ status: "error", http: res.status, ...data })); process.exit(1); }
103+
console.log(JSON.stringify(data, null, 2));
104+
}
105+
106+
async function autoRecharge(args) {
107+
const accountId = args[0];
108+
const state = args[1];
109+
if (!accountId || !state || !["on", "off"].includes(state)) {
110+
console.error(JSON.stringify({ status: "error", message: "Usage: run402 billing auto-recharge <account_id> <on|off> [--threshold <n>]" }));
111+
process.exit(1);
112+
}
113+
const thresholdStr = parseFlag(args, "--threshold");
114+
const body = {
115+
billing_account_id: accountId,
116+
enabled: state === "on",
117+
};
118+
if (thresholdStr) body.threshold = Number(thresholdStr);
119+
const res = await fetch(`${API}/billing/v1/email-packs/auto-recharge`, {
120+
method: "POST",
121+
headers: { "Content-Type": "application/json" },
122+
body: JSON.stringify(body),
123+
});
124+
const data = await res.json();
125+
if (!res.ok) { console.error(JSON.stringify({ status: "error", http: res.status, ...data })); process.exit(1); }
126+
console.log(JSON.stringify(data, null, 2));
127+
}
128+
129+
async function balance(args) {
130+
const id = args[0];
131+
if (!id) {
132+
console.error(JSON.stringify({ status: "error", message: "Usage: run402 billing balance <email-or-wallet>" }));
133+
process.exit(1);
134+
}
135+
const res = await fetch(`${API}/billing/v1/accounts/${encodeURIComponent(id)}`);
136+
const data = await res.json();
137+
if (!res.ok) { console.error(JSON.stringify({ status: "error", http: res.status, ...data })); process.exit(1); }
138+
console.log(JSON.stringify(data, null, 2));
139+
}
140+
141+
async function history(args) {
142+
const id = args[0];
143+
if (!id) {
144+
console.error(JSON.stringify({ status: "error", message: "Usage: run402 billing history <email-or-wallet> [--limit <n>]" }));
145+
process.exit(1);
146+
}
147+
const limit = parseFlag(args, "--limit") || "50";
148+
const res = await fetch(`${API}/billing/v1/accounts/${encodeURIComponent(id)}/history?limit=${encodeURIComponent(limit)}`);
149+
const data = await res.json();
150+
if (!res.ok) { console.error(JSON.stringify({ status: "error", http: res.status, ...data })); process.exit(1); }
151+
console.log(JSON.stringify(data, null, 2));
152+
}
153+
154+
export async function run(sub, args) {
155+
if (!sub || sub === "--help" || sub === "-h") { console.log(HELP); process.exit(0); }
156+
switch (sub) {
157+
case "create-email": await createEmail(args); break;
158+
case "link-wallet": await linkWallet(args); break;
159+
case "tier-checkout": await tierCheckout(args); break;
160+
case "buy-pack": await buyPack(args); break;
161+
case "auto-recharge": await autoRecharge(args); break;
162+
case "balance": await balance(args); break;
163+
case "history": await history(args); break;
164+
default:
165+
console.error(`Unknown subcommand: ${sub}\n`);
166+
console.log(HELP);
167+
process.exit(1);
168+
}
169+
}

openclaw/scripts/billing.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { run } from "../../cli/lib/billing.mjs";

src/index.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ import { registerSenderDomainSchema, handleRegisterSenderDomain } from "./tools/
8585
import { senderDomainStatusSchema, handleSenderDomainStatus } from "./tools/sender-domain-status.js";
8686
import { removeSenderDomainSchema, handleRemoveSenderDomain } from "./tools/remove-sender-domain.js";
8787

88+
// New tools — email billing accounts + Stripe tier checkout + email packs
89+
import { createEmailBillingAccountSchema, handleCreateEmailBillingAccount } from "./tools/create-email-billing-account.js";
90+
import { linkWalletToAccountSchema, handleLinkWalletToAccount } from "./tools/link-wallet-to-account.js";
91+
import { tierCheckoutSchema, handleTierCheckout } from "./tools/tier-checkout.js";
92+
import { buyEmailPackSchema, handleBuyEmailPack } from "./tools/buy-email-pack.js";
93+
import { setAutoRechargeSchema, handleSetAutoRecharge } from "./tools/set-auto-recharge.js";
94+
8895
// New tools — AI
8996
import { aiTranslateSchema, handleAiTranslate } from "./tools/ai-translate.js";
9097
import { aiModerateSchema, handleAiModerate } from "./tools/ai-moderate.js";
@@ -675,5 +682,42 @@ server.tool(
675682
async (args) => handleRemoveSenderDomain(args),
676683
);
677684

685+
// --- Email billing accounts + Stripe tier checkout + email packs ---
686+
687+
server.tool(
688+
"create_email_billing_account",
689+
"Create an email-based billing account (Stripe-only, no wallet required). Sends a verification email. Idempotent — duplicate emails return the existing account.",
690+
createEmailBillingAccountSchema,
691+
async (args) => handleCreateEmailBillingAccount(args),
692+
);
693+
694+
server.tool(
695+
"link_wallet_to_account",
696+
"Link a wallet to an existing email billing account, enabling hybrid Stripe + x402 access. Fails if the wallet is already linked elsewhere.",
697+
linkWalletToAccountSchema,
698+
async (args) => handleLinkWalletToAccount(args),
699+
);
700+
701+
server.tool(
702+
"tier_checkout",
703+
"Subscribe/renew/upgrade to a run402 tier via Stripe credit card. Alternative to x402 on-chain payment. Supports wallet or email identifier. Returns a Stripe checkout URL.",
704+
tierCheckoutSchema,
705+
async (args) => handleTierCheckout(args),
706+
);
707+
708+
server.tool(
709+
"buy_email_pack",
710+
"Buy a $5 email pack (10,000 emails, never expire). Pack credits activate when tier daily limit is exhausted AND a custom sender domain is verified. Returns a Stripe checkout URL.",
711+
buyEmailPackSchema,
712+
async (args) => handleBuyEmailPack(args),
713+
);
714+
715+
server.tool(
716+
"set_auto_recharge",
717+
"Enable or disable automatic email pack repurchase when credits drop below a threshold. Requires a saved Stripe payment method.",
718+
setAutoRechargeSchema,
719+
async (args) => handleSetAutoRecharge(args),
720+
);
721+
678722
const transport = new StdioServerTransport();
679723
await server.connect(transport);

src/tools/buy-email-pack.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { z } from "zod";
2+
import { apiRequest } from "../client.js";
3+
import { formatApiError } from "../errors.js";
4+
5+
export const buyEmailPackSchema = {
6+
email: z.string().optional().describe("Email address (for email-based accounts)"),
7+
wallet: z.string().optional().describe("Wallet address (for wallet-based accounts)"),
8+
};
9+
10+
export async function handleBuyEmailPack(args: {
11+
email?: string;
12+
wallet?: string;
13+
}): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> {
14+
if (!args.email && !args.wallet) {
15+
return {
16+
content: [{ type: "text", text: "Error: Provide either `email` or `wallet`." }],
17+
isError: true,
18+
};
19+
}
20+
21+
const body: Record<string, string> = {};
22+
if (args.wallet) body.wallet = args.wallet;
23+
else if (args.email) body.email = args.email;
24+
25+
const res = await apiRequest(`/billing/v1/email-packs/checkout`, {
26+
method: "POST",
27+
body,
28+
});
29+
30+
if (!res.ok) return formatApiError(res, "creating email pack checkout");
31+
32+
const result = res.body as { checkout_url: string; topup_id: string };
33+
return {
34+
content: [{
35+
type: "text",
36+
text: `## Email Pack Checkout Created\n\n**\$5 = 10,000 emails** (never expire)\n\n- **Topup ID:** \`${result.topup_id}\`\n\n**Send your human to complete payment:**\n${result.checkout_url}\n\nOnce paid, credits will be added to the account. Note: pack credits can only be consumed when the project has a verified custom sender domain (see \`register_sender_domain\`).`,
37+
}],
38+
};
39+
}

0 commit comments

Comments
 (0)