@lizard-build/cli 4.0.23 → 4.0.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/commands/billing.d.ts +68 -0
- package/dist/commands/billing.js +435 -0
- package/dist/commands/billing.js.map +1 -0
- package/dist/commands/metrics.d.ts +13 -0
- package/dist/commands/metrics.js +33 -2
- package/dist/commands/metrics.js.map +1 -1
- package/dist/commands/up.js +10 -3
- package/dist/commands/up.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/api.d.ts +25 -0
- package/dist/lib/api.js +81 -26
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/skills-data.generated.js +1 -1
- package/dist/lib/skills-data.generated.js.map +1 -1
- package/dist/lib/updater.d.ts +1 -1
- package/dist/lib/updater.js +1 -1
- package/package.json +1 -4
- package/skill-data/core/SKILL.md +37 -29
- package/src/commands/billing.ts +571 -0
- package/src/commands/metrics.ts +49 -2
- package/src/commands/up.ts +9 -8
- package/src/index.ts +2 -2
- package/src/lib/api.ts +81 -26
- package/src/lib/updater.ts +1 -1
- package/test/unit/billing.test.ts +347 -0
- package/test/unit/metrics.test.ts +72 -0
- package/test/unit/payment-required.test.ts +110 -0
- package/dist/commands/credits.d.ts +0 -2
- package/dist/commands/credits.js +0 -331
- package/dist/commands/credits.js.map +0 -1
- package/dist/lib/x402.d.ts +0 -23
- package/dist/lib/x402.js +0 -148
- package/dist/lib/x402.js.map +0 -1
- package/src/commands/credits.ts +0 -379
- package/src/lib/x402.ts +0 -131
- package/test/unit/x402.test.ts +0 -61
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import open from "open";
|
|
3
|
+
import * as p from "@clack/prompts";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { api, withQuery, getBaseURL, isNotFound } from "../lib/api.js";
|
|
6
|
+
import { success, isJSONMode, printJSON, table, isTTY, timeAgo, info, warn } from "../lib/format.js";
|
|
7
|
+
|
|
8
|
+
// `lizard billing` — the account's plan (docs/pro-billing-api.md on the platform
|
|
9
|
+
// repo). Pro is $19/month, taxes included, with $19 of monthly credits; usage above
|
|
10
|
+
// that is pay as you go. Unlike most commands this never takes --project/--service:
|
|
11
|
+
// billing belongs to the account, not a workspace. `lizard credits` is the old name.
|
|
12
|
+
|
|
13
|
+
/** Old prepaid credits (`plan: "payg"`) keep working until this date. */
|
|
14
|
+
export const CREDITS_END = "November 1, 2026";
|
|
15
|
+
const BILLING_PATH = "/profile/account-billing";
|
|
16
|
+
|
|
17
|
+
export function billingUrl(): string {
|
|
18
|
+
return `${getBaseURL()}${BILLING_PATH}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Subscription {
|
|
22
|
+
plan: "none" | "pro" | "payg" | "enterprise" | string;
|
|
23
|
+
status: "none" | "trialing" | "active" | "past_due" | "canceled" | string;
|
|
24
|
+
isOwner?: boolean;
|
|
25
|
+
priceCents: number;
|
|
26
|
+
taxIncluded: boolean;
|
|
27
|
+
includedCents: number;
|
|
28
|
+
trial: {
|
|
29
|
+
eligible: boolean;
|
|
30
|
+
days: number | null;
|
|
31
|
+
creditCents: number | null;
|
|
32
|
+
promoCode: string | null;
|
|
33
|
+
endsAt: number | null;
|
|
34
|
+
usedCents: number | null;
|
|
35
|
+
remainingCents: number | null;
|
|
36
|
+
};
|
|
37
|
+
period: {
|
|
38
|
+
kind: "trial" | "paid";
|
|
39
|
+
start: number;
|
|
40
|
+
end: number;
|
|
41
|
+
includedCents: number;
|
|
42
|
+
usedCents: number;
|
|
43
|
+
overageCents: number;
|
|
44
|
+
billedOverageCents: number;
|
|
45
|
+
unbilledOverageCents: number;
|
|
46
|
+
nextOverageChargeAtCents: number | null;
|
|
47
|
+
} | null;
|
|
48
|
+
nextCharge: { at: number | null; amountCents: number } | null;
|
|
49
|
+
cancelAt: number | null;
|
|
50
|
+
pastDue: boolean;
|
|
51
|
+
openInvoiceUrl: string | null;
|
|
52
|
+
paymentMethod: { brand: string; last4: string; expMonth: number; expYear: number } | null;
|
|
53
|
+
limits: { tier: string; replicasPerApp: number } | null;
|
|
54
|
+
checkoutAvailable: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface BalanceView {
|
|
58
|
+
plan: string;
|
|
59
|
+
status: "active" | "grace" | "frozen";
|
|
60
|
+
balanceCents: number;
|
|
61
|
+
overdraftLimitCents: number | null;
|
|
62
|
+
availableCents: number | null;
|
|
63
|
+
expiringCents: number;
|
|
64
|
+
expiringAt: number | null;
|
|
65
|
+
hourlyRateCents: number;
|
|
66
|
+
runwayHours: number | null;
|
|
67
|
+
graceSince: number | null;
|
|
68
|
+
graceHours: number;
|
|
69
|
+
neverFreeze: boolean;
|
|
70
|
+
invoicedMonthly: boolean;
|
|
71
|
+
autoTopup?: { enabled: boolean; thresholdCents: number; amountCents: number; disabledReason: string | null };
|
|
72
|
+
pendingPromo: { code: string; creditCents: number; expiresAt: number | null } | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface CheckoutResult {
|
|
76
|
+
url: string;
|
|
77
|
+
sessionId: string;
|
|
78
|
+
trialDays: number | null;
|
|
79
|
+
trialCreditCents: number | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface StartNowResult {
|
|
83
|
+
status: "active" | "requires_action" | "failed" | string;
|
|
84
|
+
invoiceUrl?: string | null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** New servers add the trial fields; older ones send only the first four. */
|
|
88
|
+
export interface PromoResult {
|
|
89
|
+
status: string;
|
|
90
|
+
code?: string;
|
|
91
|
+
creditCents: number;
|
|
92
|
+
expiresAt: number | null;
|
|
93
|
+
balanceCents: number;
|
|
94
|
+
trialDays?: number | null;
|
|
95
|
+
trialCreditCents?: number | null;
|
|
96
|
+
appliesTo?: "next_checkout" | "current_trial";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface Transaction {
|
|
100
|
+
id: string; kind: string; amountCents: number; balanceAfterCents: number | null;
|
|
101
|
+
description: string | null; createdAt: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface PaymentMethod {
|
|
105
|
+
id: string; brand: string; last4: string; expMonth: number; expYear: number; isDefault?: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function fmtCents(cents: number): string {
|
|
109
|
+
const sign = cents < 0 ? "-" : "";
|
|
110
|
+
return `${sign}$${(Math.abs(cents) / 100).toFixed(2)}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** $19, $4.10: whole dollars without cents. */
|
|
114
|
+
export function usd(cents: number): string {
|
|
115
|
+
return cents % 100 === 0 ? `$${cents / 100}` : fmtCents(cents);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function fmtDate(ms: number): string {
|
|
119
|
+
return new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function daysLeft(endMs: number, now = Date.now()): string {
|
|
123
|
+
const days = Math.max(0, Math.ceil((endMs - now) / 86_400_000));
|
|
124
|
+
return days === 1 ? "1 day left" : `${days} days left`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function statusLabel(status: string): string {
|
|
128
|
+
switch (status) {
|
|
129
|
+
case "active": return chalk.green(status);
|
|
130
|
+
case "trialing": return chalk.green("trial");
|
|
131
|
+
case "grace":
|
|
132
|
+
case "past_due": return chalk.yellow(status.replace("_", " "));
|
|
133
|
+
case "frozen":
|
|
134
|
+
case "canceled": return chalk.red(status);
|
|
135
|
+
default: return status;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const creditsNotice = () => `Prepaid credits end on ${CREDITS_END}. Start Pro before then: lizard billing start`;
|
|
140
|
+
|
|
141
|
+
/** Lines that describe a plan, for people. Pure, so the tests can read it. */
|
|
142
|
+
export function describeSubscription(sub: Subscription, now = Date.now()): string[] {
|
|
143
|
+
const lines: string[] = [];
|
|
144
|
+
const label = (s: string) => chalk.dim(s);
|
|
145
|
+
const owner = sub.isOwner === false ? chalk.dim(" (workspace owner's plan)") : "";
|
|
146
|
+
|
|
147
|
+
if (sub.plan === "payg") {
|
|
148
|
+
lines.push(`${chalk.bold("Prepaid credits")}${owner}`);
|
|
149
|
+
lines.push(chalk.yellow(creditsNotice()));
|
|
150
|
+
return lines;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (sub.plan === "enterprise") {
|
|
154
|
+
lines.push(`${chalk.bold("Enterprise")}${owner}`);
|
|
155
|
+
lines.push("Pay as you go, invoiced monthly.");
|
|
156
|
+
return lines;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (sub.plan !== "pro") {
|
|
160
|
+
lines.push(`${chalk.bold("No plan")}${owner}`);
|
|
161
|
+
if (sub.status === "canceled") lines.push("Pro has ended.");
|
|
162
|
+
if (sub.isOwner === false) {
|
|
163
|
+
lines.push("The workspace owner has to start Pro.");
|
|
164
|
+
} else if (!sub.checkoutAvailable) {
|
|
165
|
+
lines.push("Pro is not open for this account yet.");
|
|
166
|
+
} else if (sub.trial.eligible && sub.trial.days) {
|
|
167
|
+
lines.push(
|
|
168
|
+
`Start a ${sub.trial.days}-day Pro trial with ${usd(sub.trial.creditCents ?? 0)} in credits: ` +
|
|
169
|
+
chalk.cyan("lizard billing start"),
|
|
170
|
+
);
|
|
171
|
+
lines.push(chalk.dim(`No charge today, then ${usd(sub.priceCents)}/month, taxes included.`));
|
|
172
|
+
if (sub.trial.promoCode) lines.push(label("Promo code: ") + `${sub.trial.promoCode} (saved for the trial)`);
|
|
173
|
+
} else {
|
|
174
|
+
lines.push(
|
|
175
|
+
`Start Pro: ${usd(sub.priceCents)}/month, taxes included, with ${usd(sub.includedCents)} in credits each month: ` +
|
|
176
|
+
chalk.cyan("lizard billing start"),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return lines;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
lines.push(
|
|
183
|
+
`${chalk.bold("Pro")} ${statusLabel(sub.status)}${owner} ` +
|
|
184
|
+
chalk.dim(`${usd(sub.priceCents)}/month, taxes included`),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const period = sub.period;
|
|
188
|
+
if (sub.status === "trialing") {
|
|
189
|
+
if (sub.trial.endsAt) lines.push(label("Trial: ") + `${daysLeft(sub.trial.endsAt, now)}, ends ${fmtDate(sub.trial.endsAt)}`);
|
|
190
|
+
const credit = sub.trial.creditCents ?? period?.includedCents ?? 0;
|
|
191
|
+
const used = sub.trial.usedCents ?? period?.usedCents ?? 0;
|
|
192
|
+
const left = sub.trial.remainingCents ?? Math.max(0, credit - used);
|
|
193
|
+
lines.push(label("Trial credits: ") + `${usd(used)} of ${usd(credit)} used, ${usd(left)} left`);
|
|
194
|
+
} else if (period) {
|
|
195
|
+
lines.push(
|
|
196
|
+
label("This month: ") +
|
|
197
|
+
`${usd(period.usedCents)} of ${usd(period.includedCents)} used` +
|
|
198
|
+
chalk.dim(` (${fmtDate(period.start)} to ${fmtDate(period.end)})`),
|
|
199
|
+
);
|
|
200
|
+
if (period.kind === "paid") {
|
|
201
|
+
let overage = `${usd(period.overageCents)} this month`;
|
|
202
|
+
if (period.overageCents > 0) {
|
|
203
|
+
overage += `, ${usd(period.billedOverageCents)} invoiced so far`;
|
|
204
|
+
if (period.nextOverageChargeAtCents) {
|
|
205
|
+
overage += `; the next invoice goes out at ${usd(period.nextOverageChargeAtCents)}`;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
lines.push(label("Overage: ") + overage);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (sub.nextCharge?.at) {
|
|
213
|
+
lines.push(label("Next charge: ") + `${usd(sub.nextCharge.amountCents)} on ${fmtDate(sub.nextCharge.at)}`);
|
|
214
|
+
}
|
|
215
|
+
if (sub.cancelAt) {
|
|
216
|
+
lines.push(chalk.yellow(`Ends on ${fmtDate(sub.cancelAt)}.`) + " Run " + chalk.cyan("lizard billing resume") + " to keep Pro.");
|
|
217
|
+
}
|
|
218
|
+
if (sub.pastDue) {
|
|
219
|
+
lines.push(chalk.red("A payment failed. New resources are blocked until the open invoice is paid."));
|
|
220
|
+
if (sub.openInvoiceUrl) lines.push(label("Pay it: ") + chalk.cyan(sub.openInvoiceUrl));
|
|
221
|
+
}
|
|
222
|
+
if (sub.limits) {
|
|
223
|
+
const n = sub.limits.replicasPerApp;
|
|
224
|
+
lines.push(label("Replicas: ") + `up to ${n} per service`);
|
|
225
|
+
}
|
|
226
|
+
if (sub.paymentMethod) {
|
|
227
|
+
const pm = sub.paymentMethod;
|
|
228
|
+
lines.push(label("Card: ") + `${pm.brand} •••• ${pm.last4}` + chalk.dim(` (expires ${pm.expMonth}/${pm.expYear})`));
|
|
229
|
+
}
|
|
230
|
+
return lines;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function fetchBalance(): Promise<BalanceView> {
|
|
234
|
+
return api.get<BalanceView>("/api/billing/balance");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The prepaid credits view (old `payg` accounts until November 1, and enterprise usage). */
|
|
238
|
+
function printBalanceView(view: BalanceView) {
|
|
239
|
+
console.log(`${chalk.bold(fmtCents(view.balanceCents))} ${statusLabel(view.status)} ${chalk.dim(`(${view.plan})`)}`);
|
|
240
|
+
|
|
241
|
+
if (view.neverFreeze) {
|
|
242
|
+
console.log(chalk.dim("Exempt from billing freeze — balance can go negative with no enforcement."));
|
|
243
|
+
} else {
|
|
244
|
+
if (view.overdraftLimitCents != null) {
|
|
245
|
+
console.log(chalk.dim(`Overdraft limit: `) + fmtCents(-view.overdraftLimitCents));
|
|
246
|
+
}
|
|
247
|
+
if (view.availableCents != null) {
|
|
248
|
+
console.log(chalk.dim(`Available before freeze: `) + fmtCents(view.availableCents));
|
|
249
|
+
}
|
|
250
|
+
if (view.graceSince) {
|
|
251
|
+
const deadline = view.graceSince + view.graceHours * 3_600_000;
|
|
252
|
+
const hoursLeft = Math.max(0, Math.round((deadline - Date.now()) / 3_600_000));
|
|
253
|
+
console.log(chalk.yellow(`In grace since ${timeAgo(view.graceSince)} — freezes in ~${hoursLeft}h if unpaid.`));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (view.expiringCents > 0 && view.expiringAt) {
|
|
258
|
+
console.log(chalk.dim(`Expiring: `) + `${fmtCents(view.expiringCents)} by ${new Date(view.expiringAt).toLocaleDateString()}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (view.hourlyRateCents > 0) {
|
|
262
|
+
const rate = chalk.dim(`(~${fmtCents(view.hourlyRateCents)}/hr)`);
|
|
263
|
+
if (view.runwayHours != null) {
|
|
264
|
+
const days = (view.runwayHours / 24).toFixed(1);
|
|
265
|
+
console.log(chalk.dim(`Runway: `) + `~${view.runwayHours}h (${days}d) at current usage ${rate}`);
|
|
266
|
+
} else {
|
|
267
|
+
console.log(chalk.dim(`Current usage rate: `) + rate);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (view.autoTopup) {
|
|
272
|
+
console.log(
|
|
273
|
+
chalk.dim("Auto top-up: ") +
|
|
274
|
+
(view.autoTopup.enabled
|
|
275
|
+
? `on — tops up ${fmtCents(view.autoTopup.amountCents)} when balance drops below ${fmtCents(view.autoTopup.thresholdCents)}`
|
|
276
|
+
: "off"),
|
|
277
|
+
);
|
|
278
|
+
if (view.autoTopup.disabledReason) {
|
|
279
|
+
console.log(chalk.yellow(` disabled: ${view.autoTopup.disabledReason}`));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (view.pendingPromo) {
|
|
284
|
+
console.log(chalk.dim("Pending promo: ") + `${view.pendingPromo.code} (${fmtCents(view.pendingPromo.creditCents)})`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function showStatus(opts: { workspace?: string }) {
|
|
289
|
+
let sub: Subscription;
|
|
290
|
+
try {
|
|
291
|
+
sub = await api.get<Subscription>(withQuery("/api/billing/subscription", { workspaceId: opts.workspace }));
|
|
292
|
+
} catch (e) {
|
|
293
|
+
// A server without Pro yet: the prepaid credits view is all there is.
|
|
294
|
+
if (!isNotFound(e) || opts.workspace) throw e;
|
|
295
|
+
const view = await fetchBalance();
|
|
296
|
+
if (isJSONMode()) printJSON(view);
|
|
297
|
+
else printBalanceView(view);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const url = billingUrl();
|
|
302
|
+
const ownPrepaid = sub.plan === "payg" && sub.isOwner !== false;
|
|
303
|
+
const balance = ownPrepaid ? await fetchBalance() : null;
|
|
304
|
+
|
|
305
|
+
if (isJSONMode()) {
|
|
306
|
+
printJSON({
|
|
307
|
+
...sub,
|
|
308
|
+
billingUrl: url,
|
|
309
|
+
...(balance ? { balance, notice: `Prepaid credits end on ${CREDITS_END}.` } : {}),
|
|
310
|
+
});
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (sub.plan === "payg") {
|
|
315
|
+
if (balance) printBalanceView(balance);
|
|
316
|
+
else console.log(`${chalk.bold("Prepaid credits")}${chalk.dim(" (workspace owner's plan)")}`);
|
|
317
|
+
console.log(chalk.yellow(creditsNotice()));
|
|
318
|
+
} else {
|
|
319
|
+
for (const line of describeSubscription(sub)) console.log(line);
|
|
320
|
+
}
|
|
321
|
+
console.log(chalk.dim("Billing: ") + chalk.cyan(url));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function confirmOrExit(message: string, yes: boolean | undefined) {
|
|
325
|
+
if (yes) return;
|
|
326
|
+
if (!isTTY()) throw new Error("Use -y to confirm in non-interactive mode");
|
|
327
|
+
const confirmed = await p.confirm({ message });
|
|
328
|
+
if (p.isCancel(confirmed) || !confirmed) process.exit(5);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function openInBrowser(url: string, wanted: boolean) {
|
|
332
|
+
if (wanted && isTTY() && !isJSONMode()) await open(url).catch(() => {});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** One line for a redeemed promo code. Works with servers before and after Pro. */
|
|
336
|
+
export function describePromo(out: PromoResult): string {
|
|
337
|
+
if (out.trialDays != null) {
|
|
338
|
+
const credits = usd(out.trialCreditCents ?? out.creditCents);
|
|
339
|
+
if (out.appliesTo === "current_trial") {
|
|
340
|
+
const until = out.expiresAt ? `, until ${fmtDate(out.expiresAt)}` : "";
|
|
341
|
+
return `Your trial now runs ${out.trialDays} days with ${credits} in credits${until}.`;
|
|
342
|
+
}
|
|
343
|
+
const code = out.code ? `Code ${out.code} saved: ` : "Code saved: ";
|
|
344
|
+
return `${code}a ${out.trialDays}-day Pro trial with ${credits} in credits. Start it with \`lizard billing start\`.`;
|
|
345
|
+
}
|
|
346
|
+
if (out.status === "pending_payment_method") {
|
|
347
|
+
return `Code saved: ${fmtCents(out.creditCents)} in credits. They unlock when you start your trial in Billing.`;
|
|
348
|
+
}
|
|
349
|
+
return `Redeemed ${fmtCents(out.creditCents)}. New balance: ${fmtCents(out.balanceCents)}.`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function registerBilling(program: Command) {
|
|
353
|
+
const cmd = program
|
|
354
|
+
.command("billing")
|
|
355
|
+
.alias("credits")
|
|
356
|
+
.description("Your plan: Pro trial, this month's credits, next charge, cancel or resume (`credits` is the old name)")
|
|
357
|
+
.option("-w, --workspace <id>", "Show the plan of this workspace's owner")
|
|
358
|
+
.action(async () => {
|
|
359
|
+
await showStatus(cmd.opts());
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
cmd.hook("preAction", (thisCommand) => {
|
|
363
|
+
if (thisCommand.parent?.args[0] === "credits") {
|
|
364
|
+
warn("`lizard credits` is now `lizard billing`. The old name will go away in a later release.");
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
cmd
|
|
369
|
+
.command("status")
|
|
370
|
+
.description("Show the plan, trial, this month's credits and the next charge")
|
|
371
|
+
.option("-w, --workspace <id>", "Show the plan of this workspace's owner")
|
|
372
|
+
.action(async (opts) => {
|
|
373
|
+
// `billing` takes -w too and claims it wherever it appears on the line.
|
|
374
|
+
await showStatus({ ...cmd.opts(), ...opts });
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
cmd
|
|
378
|
+
.command("open")
|
|
379
|
+
.description("Open the Billing page in the browser")
|
|
380
|
+
.option("--no-open", "Print the link instead of opening a browser")
|
|
381
|
+
.action(async (opts) => {
|
|
382
|
+
const url = billingUrl();
|
|
383
|
+
if (isJSONMode()) {
|
|
384
|
+
printJSON({ url, opened: false });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
console.log(chalk.cyan(url));
|
|
388
|
+
await openInBrowser(url, opts.open !== false);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
cmd
|
|
392
|
+
.command("start")
|
|
393
|
+
.description("Start Pro in Stripe Checkout: the trial if the account can have one, otherwise Pro at once")
|
|
394
|
+
.option("--return-url <path>", "Dashboard path to come back to after Checkout, e.g. /projects/abc")
|
|
395
|
+
.option("--no-open", "Print the Checkout link instead of opening a browser")
|
|
396
|
+
.action(async (opts) => {
|
|
397
|
+
const out = await api.post<CheckoutResult>("/api/billing/subscription/checkout", { returnUrl: opts.returnUrl });
|
|
398
|
+
if (isJSONMode()) {
|
|
399
|
+
printJSON(out);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (out.trialDays) {
|
|
403
|
+
console.log(
|
|
404
|
+
`Your ${out.trialDays}-day trial includes ${usd(out.trialCreditCents ?? 0)} in credits. ` +
|
|
405
|
+
"No charge today, then $19/month, taxes included.",
|
|
406
|
+
);
|
|
407
|
+
} else {
|
|
408
|
+
console.log("Pro starts today: $19/month, taxes included, with $19 in credits each month.");
|
|
409
|
+
}
|
|
410
|
+
console.log(`Finish in Stripe Checkout:\n${chalk.cyan(out.url)}`);
|
|
411
|
+
await openInBrowser(out.url, opts.open !== false);
|
|
412
|
+
info(chalk.dim("Run `lizard billing` when you are done."));
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
cmd
|
|
416
|
+
.command("start-now")
|
|
417
|
+
.description("End the trial now: charge $19 and start the first paid month with $19 in credits")
|
|
418
|
+
.option("-y, --yes", "Skip confirmation")
|
|
419
|
+
.option("--no-open", "Print the payment link instead of opening a browser")
|
|
420
|
+
.action(async (opts) => {
|
|
421
|
+
await confirmOrExit("End the trial and charge $19 now?", opts.yes);
|
|
422
|
+
const out = await api.post<StartNowResult>("/api/billing/subscription/start-now", {});
|
|
423
|
+
if (out.status === "failed") {
|
|
424
|
+
throw Object.assign(
|
|
425
|
+
new Error(`The charge failed, so your trial continues. Check your card in Billing:\n ${billingUrl()}`),
|
|
426
|
+
{ code: "PAYMENT_FAILED" },
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
if (isJSONMode()) {
|
|
430
|
+
printJSON(out);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (out.status === "requires_action" && out.invoiceUrl) {
|
|
434
|
+
console.log(`Your bank needs you to confirm the payment. Your trial continues until you do:\n${chalk.cyan(out.invoiceUrl)}`);
|
|
435
|
+
await openInBrowser(out.invoiceUrl, opts.open !== false);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
success("Pro is active. We charged $19 and added $19 in credits for this month.");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
cmd
|
|
442
|
+
.command("cancel")
|
|
443
|
+
.description("Cancel Pro at the end of this month or trial; nothing is charged after that")
|
|
444
|
+
.option("-y, --yes", "Skip confirmation")
|
|
445
|
+
.action(async (opts) => {
|
|
446
|
+
await confirmOrExit("Cancel Pro at the end of the current period?", opts.yes);
|
|
447
|
+
const out = await api.post<{ cancelAt: number | null }>("/api/billing/subscription/cancel", {});
|
|
448
|
+
if (isJSONMode()) {
|
|
449
|
+
printJSON(out);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
success(
|
|
453
|
+
out.cancelAt
|
|
454
|
+
? `Pro ends on ${fmtDate(out.cancelAt)}. Run \`lizard billing resume\` to keep it.`
|
|
455
|
+
: "Pro is cancelled.",
|
|
456
|
+
);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
cmd
|
|
460
|
+
.command("resume")
|
|
461
|
+
.description("Undo a cancel: keep Pro after the current period")
|
|
462
|
+
.action(async () => {
|
|
463
|
+
const out = await api.post<{ cancelAt: null }>("/api/billing/subscription/resume", {});
|
|
464
|
+
if (isJSONMode()) {
|
|
465
|
+
printJSON(out);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
success("Pro continues. It renews as usual.");
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
cmd
|
|
472
|
+
.command("promo <code>")
|
|
473
|
+
.description("Redeem a promo code: a longer Pro trial with more trial credits")
|
|
474
|
+
.action(async (code: string) => {
|
|
475
|
+
const out = await api.post<PromoResult>("/api/billing/promo/redeem", { code });
|
|
476
|
+
if (isJSONMode()) {
|
|
477
|
+
printJSON(out);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
success(describePromo(out));
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
cmd
|
|
484
|
+
.command("balance")
|
|
485
|
+
.description(`Prepaid credits balance (old credits accounts until ${CREDITS_END}, and enterprise)`)
|
|
486
|
+
.action(async () => {
|
|
487
|
+
const view = await fetchBalance();
|
|
488
|
+
if (isJSONMode()) {
|
|
489
|
+
printJSON(view);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
printBalanceView(view);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
cmd
|
|
496
|
+
.command("transactions")
|
|
497
|
+
.alias("ledger")
|
|
498
|
+
.description("List recent balance transactions (prepaid credits and enterprise accounts)")
|
|
499
|
+
.option("-l, --limit <n>", "Max results (1-100)", "20")
|
|
500
|
+
.option("-c, --cursor <cursor>", "Pagination cursor from a previous call")
|
|
501
|
+
.option("-u, --usage", "Include daily usage-deduction rows")
|
|
502
|
+
.action(async (opts) => {
|
|
503
|
+
const page = await api.get<{ items: Transaction[]; nextCursor: string | null }>(
|
|
504
|
+
withQuery("/api/billing/transactions", {
|
|
505
|
+
limit: opts.limit,
|
|
506
|
+
cursor: opts.cursor,
|
|
507
|
+
includeUsage: opts.usage ? "1" : undefined,
|
|
508
|
+
}),
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
if (isJSONMode()) {
|
|
512
|
+
printJSON(page);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (page.items.length === 0) {
|
|
517
|
+
console.log("No transactions.");
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
table(
|
|
522
|
+
["When", "Kind", "Amount", "Balance after", "Description"],
|
|
523
|
+
page.items.map((t) => [
|
|
524
|
+
timeAgo(t.createdAt),
|
|
525
|
+
t.kind,
|
|
526
|
+
t.amountCents >= 0 ? chalk.green(fmtCents(t.amountCents)) : chalk.red(fmtCents(t.amountCents)),
|
|
527
|
+
t.balanceAfterCents != null ? fmtCents(t.balanceAfterCents) : "—",
|
|
528
|
+
t.description ?? "",
|
|
529
|
+
]),
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
if (page.nextCursor) {
|
|
533
|
+
console.log(chalk.dim(`\nMore results: lizard billing transactions --cursor ${page.nextCursor}`));
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// ── Payment methods ───────────────────────────────────────────────
|
|
538
|
+
const pm = cmd.command("payment-methods").alias("pm").description("List or remove saved cards (add a card in Billing)");
|
|
539
|
+
|
|
540
|
+
pm.command("list")
|
|
541
|
+
.description("List saved cards")
|
|
542
|
+
.action(async () => {
|
|
543
|
+
const { items } = await api.get<{ items: PaymentMethod[] }>("/api/billing/payment-methods");
|
|
544
|
+
if (isJSONMode()) {
|
|
545
|
+
printJSON(items);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (items.length === 0) {
|
|
549
|
+
console.log(`No saved cards. Add one in Billing: ${chalk.cyan(billingUrl())}`);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
table(
|
|
553
|
+
["ID", "Card", "Expires", "Default"],
|
|
554
|
+
items.map((m) => [m.id, `${m.brand} •••• ${m.last4}`, `${m.expMonth}/${m.expYear}`, m.isDefault ? "yes" : ""]),
|
|
555
|
+
);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
pm.command("remove <id>")
|
|
559
|
+
.alias("rm")
|
|
560
|
+
.description("Remove a saved card")
|
|
561
|
+
.option("-y, --yes", "Skip confirmation")
|
|
562
|
+
.action(async (id: string, opts) => {
|
|
563
|
+
await confirmOrExit(`Remove payment method ${chalk.bold(id)}?`, opts.yes);
|
|
564
|
+
const out = await api.delete<{ ok: boolean }>(`/api/billing/payment-methods/${encodeURIComponent(id)}`);
|
|
565
|
+
if (isJSONMode()) {
|
|
566
|
+
printJSON(out);
|
|
567
|
+
} else {
|
|
568
|
+
success("Payment method removed");
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
}
|
package/src/commands/metrics.ts
CHANGED
|
@@ -47,7 +47,7 @@ export function registerMetrics(program: Command) {
|
|
|
47
47
|
.option("--all", "Show all services in the project, ignoring the linked service")
|
|
48
48
|
.option("-r, --range <range>", `Time range: ${RANGES.join("|")}`, "1h")
|
|
49
49
|
.option("-w, --watch", "Live view, refreshed every 3s (Ctrl+C to stop)")
|
|
50
|
-
.option("--cost", "Show running resources, cost per hour,
|
|
50
|
+
.option("--cost", "Show running resources, cost per hour, current billing-period usage (incl. egress) and Pro credits used this month")
|
|
51
51
|
.action(async (opts) => {
|
|
52
52
|
if (opts.all && opts.service) {
|
|
53
53
|
fail("--all cannot be combined with --service. Choose all services or one service.");
|
|
@@ -521,6 +521,48 @@ async function fetchPeriodUsage(projectId: string, workspaceId: string): Promise
|
|
|
521
521
|
};
|
|
522
522
|
}
|
|
523
523
|
|
|
524
|
+
/** This month's Pro credits for the workspace owner's account; null for other plans. */
|
|
525
|
+
interface ProCredits {
|
|
526
|
+
status: string;
|
|
527
|
+
kind: "trial" | "paid";
|
|
528
|
+
periodStart: number;
|
|
529
|
+
periodEnd: number;
|
|
530
|
+
includedCents: number;
|
|
531
|
+
usedCents: number;
|
|
532
|
+
overageCents: number;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function fetchProCredits(workspaceId: string): Promise<ProCredits | null> {
|
|
536
|
+
const sub = await api
|
|
537
|
+
.get<{
|
|
538
|
+
plan: string;
|
|
539
|
+
status: string;
|
|
540
|
+
period: { kind: "trial" | "paid"; start: number; end: number; includedCents: number; usedCents: number; overageCents: number } | null;
|
|
541
|
+
}>(withQuery("/api/billing/subscription", { workspaceId }))
|
|
542
|
+
.catch(() => null);
|
|
543
|
+
if (!sub || sub.plan !== "pro" || !sub.period) return null;
|
|
544
|
+
return {
|
|
545
|
+
status: sub.status,
|
|
546
|
+
kind: sub.period.kind,
|
|
547
|
+
periodStart: sub.period.start,
|
|
548
|
+
periodEnd: sub.period.end,
|
|
549
|
+
includedCents: sub.period.includedCents,
|
|
550
|
+
usedCents: sub.period.usedCents,
|
|
551
|
+
overageCents: sub.period.overageCents,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const dollars = (cents: number) => (cents % 100 === 0 ? `$${cents / 100}` : `$${(cents / 100).toFixed(2)}`);
|
|
556
|
+
|
|
557
|
+
/** "$4.10 of $19 in monthly credits used, overage $0" — the account-wide line for Pro. */
|
|
558
|
+
export function proCreditsLine(pro: ProCredits): string {
|
|
559
|
+
if (pro.kind === "trial") {
|
|
560
|
+
const left = Math.max(0, pro.includedCents - pro.usedCents);
|
|
561
|
+
return `${dollars(pro.usedCents)} of ${dollars(pro.includedCents)} in trial credits used, ${dollars(left)} left`;
|
|
562
|
+
}
|
|
563
|
+
return `${dollars(pro.usedCents)} of ${dollars(pro.includedCents)} in monthly credits used, overage ${dollars(pro.overageCents)}`;
|
|
564
|
+
}
|
|
565
|
+
|
|
524
566
|
function fmtPeriodDate(ms: number): string {
|
|
525
567
|
return new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
526
568
|
}
|
|
@@ -532,10 +574,12 @@ async function showCost(projectId: string, scope: ResourceScope) {
|
|
|
532
574
|
|
|
533
575
|
let data: { resources: BillingResource[]; costPerHour: number };
|
|
534
576
|
let usage: PeriodUsage | null;
|
|
577
|
+
let pro: ProCredits | null;
|
|
535
578
|
try {
|
|
536
|
-
[data, usage] = await Promise.all([
|
|
579
|
+
[data, usage, pro] = await Promise.all([
|
|
537
580
|
api.get(withQuery("/api/billing/live", { workspaceId: scope.workspaceId })),
|
|
538
581
|
fetchPeriodUsage(projectId, scope.workspaceId),
|
|
582
|
+
fetchProCredits(scope.workspaceId),
|
|
539
583
|
]);
|
|
540
584
|
} catch (e) {
|
|
541
585
|
if (e instanceof APIError && e.status === 403) {
|
|
@@ -554,6 +598,8 @@ async function showCost(projectId: string, scope: ResourceScope) {
|
|
|
554
598
|
projectCostPerHour: projectCost,
|
|
555
599
|
workspaceCostPerHour: data.costPerHour,
|
|
556
600
|
currentPeriod: usage,
|
|
601
|
+
// Pro only: the account's credits this month (the trial's while trialing).
|
|
602
|
+
...(pro ? { proCredits: pro } : {}),
|
|
557
603
|
});
|
|
558
604
|
return;
|
|
559
605
|
}
|
|
@@ -580,6 +626,7 @@ async function showCost(projectId: string, scope: ResourceScope) {
|
|
|
580
626
|
);
|
|
581
627
|
}
|
|
582
628
|
console.log(chalk.dim("Workspace ") + `$${data.costPerHour.toFixed(4)}/hr`);
|
|
629
|
+
if (pro) console.log(chalk.dim(pro.kind === "trial" ? "Pro trial " : "Pro ") + proCreditsLine(pro));
|
|
583
630
|
|
|
584
631
|
if (usage && usage.rows.length > 0) {
|
|
585
632
|
console.log();
|
package/src/commands/up.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { createTarball } from "../lib/archive.js";
|
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import * as readline from "node:readline";
|
|
9
|
-
import { api, streamSSE, getBaseURL, clientHeaders, APIError, type ResourceScope } from "../lib/api.js";
|
|
9
|
+
import { api, streamSSE, getBaseURL, clientHeaders, APIError, apiErrorFrom, isPaymentRequired, type ResourceScope } from "../lib/api.js";
|
|
10
10
|
import { updateProjectLink, DEFAULT_REGION } from "../lib/config.js";
|
|
11
11
|
import { resolveContext, getScope } from "../lib/resolve.js";
|
|
12
12
|
import { ensureLinked } from "./init.js";
|
|
@@ -191,13 +191,14 @@ async function deployFromLocal(args: {
|
|
|
191
191
|
try {
|
|
192
192
|
parsed = text ? JSON.parse(text) : null;
|
|
193
193
|
} catch {}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
194
|
+
if (parsed && typeof parsed === "object") {
|
|
195
|
+
const err = apiErrorFrom(res.status, res.statusText, parsed);
|
|
196
|
+
// "Start Pro to deploy" is not an upload failure: print the platform's
|
|
197
|
+
// sentence and its Billing link as they are.
|
|
198
|
+
if (!isPaymentRequired(err)) err.message = `Upload failed (${res.status}): ${err.message}`;
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
throw new APIError(res.status, `Upload failed (${res.status}): ${text || res.statusText}`, "", null);
|
|
201
202
|
}
|
|
202
203
|
newApp = (await res.json()) as App & { buildId?: string };
|
|
203
204
|
spinner.succeed(`Service ${chalk.bold(newApp.name)} ${args.existingServiceId ? "updated" : "created"}`);
|