@lizard-build/cli 4.0.23 → 4.0.25
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/install.sh +26 -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
package/src/index.ts
CHANGED
|
@@ -60,8 +60,8 @@ function addAgentsPointerToSubcommands(cmd: Command) {
|
|
|
60
60
|
|
|
61
61
|
// Commands (alphabetical by command name)
|
|
62
62
|
import { registerAdd } from "./commands/add.js";
|
|
63
|
+
import { registerBilling } from "./commands/billing.js";
|
|
63
64
|
import { registerConfig } from "./commands/config.js";
|
|
64
|
-
import { registerCredits } from "./commands/credits.js";
|
|
65
65
|
import { registerDocs } from "./commands/docs.js";
|
|
66
66
|
import { registerDomain } from "./commands/domain.js";
|
|
67
67
|
import { registerEvents } from "./commands/events.js";
|
|
@@ -156,8 +156,8 @@ program.exitOverride();
|
|
|
156
156
|
|
|
157
157
|
// Register all commands (alphabetical)
|
|
158
158
|
registerAdd(program);
|
|
159
|
+
registerBilling(program);
|
|
159
160
|
registerConfig(program);
|
|
160
|
-
registerCredits(program);
|
|
161
161
|
registerDocs(program);
|
|
162
162
|
registerDomain(program);
|
|
163
163
|
registerEvents(program);
|
package/src/lib/api.ts
CHANGED
|
@@ -79,6 +79,83 @@ export class APIError extends Error {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Builds the APIError for a failed platform call from its parsed JSON body (or null).
|
|
84
|
+
*
|
|
85
|
+
* The platform uses two error shapes. Most routes send {error: "human text"}; the
|
|
86
|
+
* billing and credits routes send {error: "SCREAMING_CODE", message: "human text"}.
|
|
87
|
+
* Taking `error` unconditionally printed the bare code for the second shape and threw
|
|
88
|
+
* away the sentence explaining it — so a scoped key hitting billing showed
|
|
89
|
+
* "ACCOUNT_SCOPE_REQUIRED" and nothing else.
|
|
90
|
+
*
|
|
91
|
+
* When the body carries a link the user has to open next (Billing, the open invoice,
|
|
92
|
+
* the Pro trial), the link goes on a second line of the message, so every command
|
|
93
|
+
* prints it without handling the error itself.
|
|
94
|
+
*/
|
|
95
|
+
export function apiErrorFrom(status: number, statusText: string, body: unknown): APIError {
|
|
96
|
+
let msg = statusText;
|
|
97
|
+
let code = "";
|
|
98
|
+
if (body && typeof body === "object") {
|
|
99
|
+
const j = body as Record<string, unknown>;
|
|
100
|
+
const error = typeof j.error === "string" ? j.error : "";
|
|
101
|
+
const message = typeof j.message === "string" ? j.message : "";
|
|
102
|
+
const errIsCode = /^[A-Z][A-Z0-9_]*$/.test(error);
|
|
103
|
+
msg = (errIsCode ? message || error : error) || message || msg;
|
|
104
|
+
code = (typeof j.code === "string" && j.code) || (errIsCode ? error : "") || "";
|
|
105
|
+
const next = errorLink(j);
|
|
106
|
+
if (next) msg = `${msg}\n ${next.label}: ${next.url}${next.hint ? ` (or run \`${next.hint}\`)` : ""}`;
|
|
107
|
+
}
|
|
108
|
+
return new APIError(status, msg, code, body);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Old prepaid credits (`plan: "payg"`) statuses: the next step is the Credits page. */
|
|
112
|
+
const CREDITS_STATUSES = new Set(["grace", "frozen", "card_required", "credits_required"]);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* True for the platform's "pay first" answer to creating anything. Servers send
|
|
116
|
+
* `code: "PAYMENT_REQUIRED"`; older ones only `error: "INSUFFICIENT_CREDITS"`.
|
|
117
|
+
*/
|
|
118
|
+
export function isPaymentRequired(err: unknown): boolean {
|
|
119
|
+
if (!(err instanceof APIError)) return false;
|
|
120
|
+
return isPaymentRequiredBody(err.body);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isPaymentRequiredBody(body: unknown): boolean {
|
|
124
|
+
if (!body || typeof body !== "object") return false;
|
|
125
|
+
const j = body as Record<string, unknown>;
|
|
126
|
+
return j.code === "PAYMENT_REQUIRED" || j.error === "INSUFFICIENT_CREDITS";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function httpUrl(value: unknown): string | null {
|
|
130
|
+
return typeof value === "string" && /^https?:\/\//.test(value) ? value : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The page an error body points to, with a label, and a CLI command that does the same. */
|
|
134
|
+
export function errorLink(body: unknown): { label: string; url: string; hint?: string } | null {
|
|
135
|
+
if (!body || typeof body !== "object") return null;
|
|
136
|
+
const j = body as Record<string, unknown>;
|
|
137
|
+
const invoiceUrl = httpUrl(j.invoiceUrl);
|
|
138
|
+
if (invoiceUrl) return { label: "Pay the open invoice", url: invoiceUrl };
|
|
139
|
+
const billingUrl = httpUrl(j.billingUrl);
|
|
140
|
+
if (!isPaymentRequiredBody(j)) return billingUrl ? { label: "Billing", url: billingUrl } : null;
|
|
141
|
+
|
|
142
|
+
const subscribeUrl = httpUrl(j.subscribeUrl);
|
|
143
|
+
const topupUrl = httpUrl(j.topupUrl);
|
|
144
|
+
const status = typeof j.status === "string" ? j.status : "";
|
|
145
|
+
if (status === "trial_available" && subscribeUrl) {
|
|
146
|
+
return { label: "Start your trial", url: subscribeUrl, hint: "lizard billing start" };
|
|
147
|
+
}
|
|
148
|
+
if (status === "subscription_required" && subscribeUrl) {
|
|
149
|
+
return { label: "Start Pro", url: subscribeUrl, hint: "lizard billing start" };
|
|
150
|
+
}
|
|
151
|
+
if (status === "trial_credits_used" && billingUrl) {
|
|
152
|
+
return { label: "Billing", url: billingUrl, hint: "lizard billing start-now" };
|
|
153
|
+
}
|
|
154
|
+
if (CREDITS_STATUSES.has(status) && topupUrl) return { label: "Add credits", url: topupUrl };
|
|
155
|
+
const url = billingUrl ?? subscribeUrl ?? topupUrl;
|
|
156
|
+
return url ? { label: "Billing", url } : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
82
159
|
export function isNotFound(err: unknown): boolean {
|
|
83
160
|
return err instanceof APIError && err.status === 404;
|
|
84
161
|
}
|
|
@@ -137,22 +214,11 @@ async function request<T = any>(
|
|
|
137
214
|
});
|
|
138
215
|
|
|
139
216
|
if (!res.ok) {
|
|
140
|
-
let msg = res.statusText;
|
|
141
|
-
let code = "";
|
|
142
217
|
let body: unknown = null;
|
|
143
218
|
try {
|
|
144
|
-
|
|
145
|
-
body = j;
|
|
146
|
-
// The platform uses two error shapes. Most routes send {error: "human text"};
|
|
147
|
-
// the billing and credits routes send {error: "SCREAMING_CODE", message: "human
|
|
148
|
-
// text"}. Taking j.error unconditionally printed the bare code for the second
|
|
149
|
-
// shape and threw away the sentence explaining it — so a scoped key hitting
|
|
150
|
-
// billing showed "ACCOUNT_SCOPE_REQUIRED" and nothing else.
|
|
151
|
-
const errIsCode = typeof j.error === "string" && /^[A-Z][A-Z0-9_]*$/.test(j.error);
|
|
152
|
-
msg = (errIsCode ? j.message || j.error : j.error) || j.message || msg;
|
|
153
|
-
code = j.code || (errIsCode ? j.error : "") || "";
|
|
219
|
+
body = await res.json();
|
|
154
220
|
} catch {}
|
|
155
|
-
throw
|
|
221
|
+
throw apiErrorFrom(res.status, res.statusText, body);
|
|
156
222
|
}
|
|
157
223
|
|
|
158
224
|
const text = await res.text();
|
|
@@ -170,22 +236,11 @@ export async function getRawText(path: string): Promise<string> {
|
|
|
170
236
|
|
|
171
237
|
const res = await fetch(url, { method: "GET", headers });
|
|
172
238
|
if (!res.ok) {
|
|
173
|
-
let msg = res.statusText;
|
|
174
|
-
let code = "";
|
|
175
239
|
let body: unknown = null;
|
|
176
240
|
try {
|
|
177
|
-
|
|
178
|
-
body = j;
|
|
179
|
-
// The platform uses two error shapes. Most routes send {error: "human text"};
|
|
180
|
-
// the billing and credits routes send {error: "SCREAMING_CODE", message: "human
|
|
181
|
-
// text"}. Taking j.error unconditionally printed the bare code for the second
|
|
182
|
-
// shape and threw away the sentence explaining it — so a scoped key hitting
|
|
183
|
-
// billing showed "ACCOUNT_SCOPE_REQUIRED" and nothing else.
|
|
184
|
-
const errIsCode = typeof j.error === "string" && /^[A-Z][A-Z0-9_]*$/.test(j.error);
|
|
185
|
-
msg = (errIsCode ? j.message || j.error : j.error) || j.message || msg;
|
|
186
|
-
code = j.code || (errIsCode ? j.error : "") || "";
|
|
241
|
+
body = await res.json();
|
|
187
242
|
} catch {}
|
|
188
|
-
throw
|
|
243
|
+
throw apiErrorFrom(res.status, res.statusText, body);
|
|
189
244
|
}
|
|
190
245
|
return res.text();
|
|
191
246
|
}
|
package/src/lib/updater.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { join, dirname } from "node:path";
|
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
7
|
|
|
8
|
-
export const CURRENT_VERSION = "4.0.
|
|
8
|
+
export const CURRENT_VERSION = "4.0.25";
|
|
9
9
|
const RELEASES_API = "https://api.github.com/repos/lizard-build/lizard-cli/releases/latest";
|
|
10
10
|
const RELEASE_BASE = "https://github.com/lizard-build/lizard-cli/releases/latest/download";
|
|
11
11
|
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { registerBilling, describePromo, type Subscription } from "../../src/commands/billing.js";
|
|
4
|
+
import { setAccessToken, setBaseURL } from "../../src/lib/api.js";
|
|
5
|
+
import { setJSONMode } from "../../src/lib/format.js";
|
|
6
|
+
|
|
7
|
+
const fetchMock = vi.fn();
|
|
8
|
+
let stdout: string[];
|
|
9
|
+
let stderr: string[];
|
|
10
|
+
|
|
11
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
12
|
+
const out = () => stdout.join("\n").replace(ANSI, "");
|
|
13
|
+
const err = () => stderr.join("").replace(ANSI, "");
|
|
14
|
+
|
|
15
|
+
function reply(status: number, body: unknown) {
|
|
16
|
+
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Answers each request by "METHOD path" (path includes the query). */
|
|
20
|
+
function routes(table: Record<string, () => Response>) {
|
|
21
|
+
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
|
|
22
|
+
const key = `${init?.method ?? "GET"} ${new URL(url).pathname}${new URL(url).search}`;
|
|
23
|
+
const handler = table[key];
|
|
24
|
+
if (!handler) return reply(404, { error: `no route ${key}` });
|
|
25
|
+
return handler();
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function calls() {
|
|
30
|
+
return fetchMock.mock.calls.map(([url, init]) => ({
|
|
31
|
+
key: `${init?.method ?? "GET"} ${new URL(url).pathname}${new URL(url).search}`,
|
|
32
|
+
body: init?.body ? JSON.parse(init.body as string) : undefined,
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function run(args: string[]) {
|
|
37
|
+
const program = new Command().exitOverride().configureOutput({ writeErr: () => {}, writeOut: () => {} });
|
|
38
|
+
registerBilling(program);
|
|
39
|
+
return program.parseAsync(args, { from: "user" });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DAY = 86_400_000;
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
|
|
45
|
+
const base: Subscription = {
|
|
46
|
+
plan: "pro",
|
|
47
|
+
status: "trialing",
|
|
48
|
+
isOwner: true,
|
|
49
|
+
priceCents: 1900,
|
|
50
|
+
taxIncluded: true,
|
|
51
|
+
includedCents: 1900,
|
|
52
|
+
trial: { eligible: false, days: 7, creditCents: 500, promoCode: null, endsAt: now + 5 * DAY - 1000, usedCents: 120, remainingCents: 380 },
|
|
53
|
+
period: {
|
|
54
|
+
kind: "trial", start: now - 2 * DAY, end: now + 5 * DAY, includedCents: 500, usedCents: 120,
|
|
55
|
+
overageCents: 0, billedOverageCents: 0, unbilledOverageCents: 0, nextOverageChargeAtCents: null,
|
|
56
|
+
},
|
|
57
|
+
nextCharge: { at: now + 5 * DAY, amountCents: 1900 },
|
|
58
|
+
cancelAt: null,
|
|
59
|
+
pastDue: false,
|
|
60
|
+
openInvoiceUrl: null,
|
|
61
|
+
paymentMethod: { brand: "visa", last4: "4242", expMonth: 12, expYear: 2030 },
|
|
62
|
+
limits: { tier: "trial", replicasPerApp: 1 },
|
|
63
|
+
checkoutAvailable: true,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const paid: Subscription = {
|
|
67
|
+
...base,
|
|
68
|
+
status: "active",
|
|
69
|
+
trial: { ...base.trial, endsAt: null, usedCents: null, remainingCents: null },
|
|
70
|
+
period: {
|
|
71
|
+
kind: "paid", start: now - 10 * DAY, end: now + 20 * DAY, includedCents: 1900, usedCents: 3140,
|
|
72
|
+
overageCents: 1240, billedOverageCents: 0, unbilledOverageCents: 1240, nextOverageChargeAtCents: 2000,
|
|
73
|
+
},
|
|
74
|
+
nextCharge: { at: now + 20 * DAY, amountCents: 3140 },
|
|
75
|
+
limits: { tier: "pro", replicasPerApp: 5 },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const none: Subscription = {
|
|
79
|
+
...base,
|
|
80
|
+
plan: "none",
|
|
81
|
+
status: "none",
|
|
82
|
+
trial: { eligible: true, days: 7, creditCents: 500, promoCode: null, endsAt: null, usedCents: null, remainingCents: null },
|
|
83
|
+
period: null,
|
|
84
|
+
nextCharge: null,
|
|
85
|
+
paymentMethod: null,
|
|
86
|
+
limits: null,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const balance = {
|
|
90
|
+
plan: "payg", status: "active", balanceCents: 1234, overdraftLimitCents: 100, availableCents: 1334,
|
|
91
|
+
expiringCents: 0, expiringAt: null, hourlyRateCents: 0, runwayHours: null, graceSince: null, graceHours: 72,
|
|
92
|
+
neverFreeze: false, invoicedMonthly: false, pendingPromo: null,
|
|
93
|
+
autoTopup: { enabled: false, thresholdCents: 500, amountCents: 2000, disabledReason: null },
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
fetchMock.mockReset();
|
|
98
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
99
|
+
setBaseURL("https://lizard.build");
|
|
100
|
+
setAccessToken("test-token");
|
|
101
|
+
setJSONMode(false);
|
|
102
|
+
stdout = [];
|
|
103
|
+
stderr = [];
|
|
104
|
+
vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => { stdout.push(a.join(" ")); });
|
|
105
|
+
vi.spyOn(process.stderr, "write").mockImplementation((chunk: any) => { stderr.push(String(chunk)); return true; });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
afterEach(() => {
|
|
109
|
+
vi.unstubAllGlobals();
|
|
110
|
+
vi.restoreAllMocks();
|
|
111
|
+
setJSONMode(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("lizard billing (status)", () => {
|
|
115
|
+
test("a trial shows days left, trial credits, the next charge and the Billing link", async () => {
|
|
116
|
+
routes({ "GET /api/billing/subscription": () => reply(200, base) });
|
|
117
|
+
await run(["billing"]);
|
|
118
|
+
const text = out();
|
|
119
|
+
expect(text).toContain("Pro trial");
|
|
120
|
+
expect(text).toContain("$19/month, taxes included");
|
|
121
|
+
expect(text).toContain("Trial: 5 days left");
|
|
122
|
+
expect(text).toContain("Trial credits: $1.20 of $5 used, $3.80 left");
|
|
123
|
+
expect(text).toMatch(/Next charge: \$19 on \w{3} \d+, \d{4}/);
|
|
124
|
+
expect(text).toContain("Replicas: up to 1 per service");
|
|
125
|
+
expect(text).toContain("Card: visa •••• 4242");
|
|
126
|
+
expect(text).toContain("Billing: https://lizard.build/profile/account-billing");
|
|
127
|
+
expect(err()).not.toContain("lizard credits");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("a paid month shows $X of $19 used and the overage", async () => {
|
|
131
|
+
routes({ "GET /api/billing/subscription": () => reply(200, paid) });
|
|
132
|
+
await run(["billing", "status"]);
|
|
133
|
+
const text = out();
|
|
134
|
+
expect(text).toContain("Pro active");
|
|
135
|
+
expect(text).toContain("This month: $31.40 of $19 used");
|
|
136
|
+
expect(text).toContain("Overage: $12.40 this month, $0 invoiced so far; the next invoice goes out at $20");
|
|
137
|
+
expect(text).toMatch(/Next charge: \$31\.40 on/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("cancelling and past due are spelled out", async () => {
|
|
141
|
+
routes({
|
|
142
|
+
"GET /api/billing/subscription": () => reply(200, {
|
|
143
|
+
...paid, status: "past_due", cancelAt: now + 20 * DAY, nextCharge: null, pastDue: true,
|
|
144
|
+
openInvoiceUrl: "https://invoice.stripe.com/i/x",
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
await run(["billing"]);
|
|
148
|
+
const text = out();
|
|
149
|
+
expect(text).toContain("Pro past due");
|
|
150
|
+
expect(text).toMatch(/Ends on \w{3} \d+, \d{4}\. Run lizard billing resume to keep Pro\./);
|
|
151
|
+
expect(text).toContain("A payment failed.");
|
|
152
|
+
expect(text).toContain("Pay it: https://invoice.stripe.com/i/x");
|
|
153
|
+
expect(text).not.toContain("Next charge");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("no plan offers the trial", async () => {
|
|
157
|
+
routes({ "GET /api/billing/subscription": () => reply(200, { ...none, trial: { ...none.trial, promoCode: "ROST", days: 31, creditCents: 10000 } }) });
|
|
158
|
+
await run(["billing"]);
|
|
159
|
+
const text = out();
|
|
160
|
+
expect(text).toContain("No plan");
|
|
161
|
+
expect(text).toContain("Start a 31-day Pro trial with $100 in credits: lizard billing start");
|
|
162
|
+
expect(text).toContain("No charge today, then $19/month, taxes included.");
|
|
163
|
+
expect(text).toContain("Promo code: ROST");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("no plan and the trial used offers Pro", async () => {
|
|
167
|
+
routes({ "GET /api/billing/subscription": () => reply(200, { ...none, status: "canceled", trial: { ...none.trial, eligible: false } }) });
|
|
168
|
+
await run(["billing"]);
|
|
169
|
+
expect(out()).toContain("Pro has ended.");
|
|
170
|
+
expect(out()).toContain("Start Pro: $19/month, taxes included, with $19 in credits each month: lizard billing start");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("enterprise is invoiced monthly", async () => {
|
|
174
|
+
routes({ "GET /api/billing/subscription": () => reply(200, { ...none, plan: "enterprise" }) });
|
|
175
|
+
await run(["billing"]);
|
|
176
|
+
expect(out()).toContain("Enterprise");
|
|
177
|
+
expect(out()).toContain("Pay as you go, invoiced monthly.");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("old prepaid credits keep the balance view and say when credits end", async () => {
|
|
181
|
+
routes({
|
|
182
|
+
"GET /api/billing/subscription": () => reply(200, { ...none, plan: "payg" }),
|
|
183
|
+
"GET /api/billing/balance": () => reply(200, balance),
|
|
184
|
+
});
|
|
185
|
+
await run(["billing"]);
|
|
186
|
+
const text = out();
|
|
187
|
+
expect(text).toContain("$12.34 active (payg)");
|
|
188
|
+
expect(text).toContain("Available before freeze: $13.34");
|
|
189
|
+
expect(text).toContain("Prepaid credits end on November 1, 2026. Start Pro before then: lizard billing start");
|
|
190
|
+
expect(text).toContain("Billing: https://lizard.build/profile/account-billing");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("--json prints the plan with the Billing link, and the balance for prepaid credits", async () => {
|
|
194
|
+
setJSONMode(true);
|
|
195
|
+
routes({
|
|
196
|
+
"GET /api/billing/subscription": () => reply(200, { ...none, plan: "payg" }),
|
|
197
|
+
"GET /api/billing/balance": () => reply(200, balance),
|
|
198
|
+
});
|
|
199
|
+
await run(["billing"]);
|
|
200
|
+
const json = JSON.parse(stdout.join("\n"));
|
|
201
|
+
expect(json.plan).toBe("payg");
|
|
202
|
+
expect(json.billingUrl).toBe("https://lizard.build/profile/account-billing");
|
|
203
|
+
expect(json.balance.balanceCents).toBe(1234);
|
|
204
|
+
expect(json.notice).toBe("Prepaid credits end on November 1, 2026.");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("a server without Pro falls back to the balance view", async () => {
|
|
208
|
+
routes({ "GET /api/billing/balance": () => reply(200, balance) });
|
|
209
|
+
await run(["billing"]);
|
|
210
|
+
expect(out()).toContain("$12.34 active (payg)");
|
|
211
|
+
expect(out()).not.toContain("November 1");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("-w reads the workspace owner's plan, before or after the subcommand", async () => {
|
|
215
|
+
routes({ "GET /api/billing/subscription?workspaceId=ws_1": () => reply(200, { ...paid, isOwner: false, paymentMethod: null }) });
|
|
216
|
+
await run(["billing", "status", "-w", "ws_1"]);
|
|
217
|
+
await run(["billing", "-w", "ws_1"]);
|
|
218
|
+
expect(calls().map((c) => c.key)).toEqual([
|
|
219
|
+
"GET /api/billing/subscription?workspaceId=ws_1",
|
|
220
|
+
"GET /api/billing/subscription?workspaceId=ws_1",
|
|
221
|
+
]);
|
|
222
|
+
expect(out()).toContain("(workspace owner's plan)");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("`lizard credits` still works and says it is now `lizard billing`", async () => {
|
|
226
|
+
routes({ "GET /api/billing/subscription": () => reply(200, base) });
|
|
227
|
+
await run(["credits"]);
|
|
228
|
+
expect(out()).toContain("Pro trial");
|
|
229
|
+
expect(err()).toContain("`lizard credits` is now `lizard billing`");
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("lizard billing actions", () => {
|
|
234
|
+
test("open prints the Billing page", async () => {
|
|
235
|
+
await run(["billing", "open", "--no-open"]);
|
|
236
|
+
expect(out()).toBe("https://lizard.build/profile/account-billing");
|
|
237
|
+
setJSONMode(true);
|
|
238
|
+
stdout = [];
|
|
239
|
+
await run(["billing", "open"]);
|
|
240
|
+
expect(JSON.parse(stdout.join("\n"))).toEqual({ url: "https://lizard.build/profile/account-billing", opened: false });
|
|
241
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("start opens Checkout for the trial", async () => {
|
|
245
|
+
routes({
|
|
246
|
+
"POST /api/billing/subscription/checkout": () => reply(200, {
|
|
247
|
+
url: "https://checkout.stripe.com/c/pay/cs_1", sessionId: "cs_1", trialDays: 7, trialCreditCents: 500,
|
|
248
|
+
}),
|
|
249
|
+
});
|
|
250
|
+
await run(["billing", "start", "--return-url", "/projects/abc", "--no-open"]);
|
|
251
|
+
expect(calls()).toEqual([{ key: "POST /api/billing/subscription/checkout", body: { returnUrl: "/projects/abc" } }]);
|
|
252
|
+
expect(out()).toContain("Your 7-day trial includes $5 in credits. No charge today, then $19/month, taxes included.");
|
|
253
|
+
expect(out()).toContain("https://checkout.stripe.com/c/pay/cs_1");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("start without a trial says Pro starts today; --json prints the session", async () => {
|
|
257
|
+
const session = { url: "https://checkout.stripe.com/c/pay/cs_2", sessionId: "cs_2", trialDays: null, trialCreditCents: null };
|
|
258
|
+
routes({ "POST /api/billing/subscription/checkout": () => reply(200, session) });
|
|
259
|
+
await run(["billing", "start", "--no-open"]);
|
|
260
|
+
expect(out()).toContain("Pro starts today: $19/month, taxes included, with $19 in credits each month.");
|
|
261
|
+
setJSONMode(true);
|
|
262
|
+
stdout = [];
|
|
263
|
+
await run(["billing", "start"]);
|
|
264
|
+
expect(JSON.parse(stdout.join("\n"))).toEqual(session);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("start passes the platform's refusal through", async () => {
|
|
268
|
+
routes({
|
|
269
|
+
"POST /api/billing/subscription/checkout": () => reply(409, { error: "ALREADY_SUBSCRIBED", message: "This account already has Pro" }),
|
|
270
|
+
});
|
|
271
|
+
await expect(run(["billing", "start"])).rejects.toMatchObject({ status: 409, code: "ALREADY_SUBSCRIBED", message: "This account already has Pro" });
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("start-now needs -y without a terminal", async () => {
|
|
275
|
+
await expect(run(["billing", "start-now"])).rejects.toThrow("Use -y to confirm");
|
|
276
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("start-now charges and reports each outcome", async () => {
|
|
280
|
+
routes({ "POST /api/billing/subscription/start-now": () => reply(200, { status: "active" }) });
|
|
281
|
+
await run(["billing", "start-now", "-y"]);
|
|
282
|
+
expect(err()).toContain("Pro is active. We charged $19 and added $19 in credits for this month.");
|
|
283
|
+
|
|
284
|
+
routes({ "POST /api/billing/subscription/start-now": () => reply(200, { status: "requires_action", invoiceUrl: "https://invoice.stripe.com/i/3ds" }) });
|
|
285
|
+
await run(["billing", "start-now", "-y", "--no-open"]);
|
|
286
|
+
expect(out()).toContain("Your bank needs you to confirm the payment.");
|
|
287
|
+
expect(out()).toContain("https://invoice.stripe.com/i/3ds");
|
|
288
|
+
|
|
289
|
+
routes({ "POST /api/billing/subscription/start-now": () => reply(200, { status: "failed" }) });
|
|
290
|
+
await expect(run(["billing", "start-now", "-y"])).rejects.toMatchObject({ code: "PAYMENT_FAILED" });
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("cancel and resume", async () => {
|
|
294
|
+
const cancelAt = Date.UTC(2026, 10, 5, 12);
|
|
295
|
+
routes({
|
|
296
|
+
"POST /api/billing/subscription/cancel": () => reply(200, { cancelAt }),
|
|
297
|
+
"POST /api/billing/subscription/resume": () => reply(200, { cancelAt: null }),
|
|
298
|
+
});
|
|
299
|
+
await expect(run(["billing", "cancel"])).rejects.toThrow("Use -y to confirm");
|
|
300
|
+
await run(["billing", "cancel", "-y"]);
|
|
301
|
+
expect(err()).toContain("Pro ends on Nov 5, 2026. Run `lizard billing resume` to keep it.");
|
|
302
|
+
await run(["billing", "resume"]);
|
|
303
|
+
expect(err()).toContain("Pro continues.");
|
|
304
|
+
expect(calls().map((c) => c.key)).toEqual(["POST /api/billing/subscription/cancel", "POST /api/billing/subscription/resume"]);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("promo prints the trial it gives", async () => {
|
|
308
|
+
routes({
|
|
309
|
+
"POST /api/billing/promo/redeem": () => reply(200, {
|
|
310
|
+
status: "pending_payment_method", code: "ROST", creditCents: 10000, expiresAt: null, balanceCents: 0,
|
|
311
|
+
trialDays: 31, trialCreditCents: 10000, appliesTo: "next_checkout",
|
|
312
|
+
}),
|
|
313
|
+
});
|
|
314
|
+
await run(["billing", "promo", "ROST"]);
|
|
315
|
+
expect(calls()).toEqual([{ key: "POST /api/billing/promo/redeem", body: { code: "ROST" } }]);
|
|
316
|
+
expect(err()).toContain("Code ROST saved: a 31-day Pro trial with $100 in credits. Start it with `lizard billing start`.");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("promo errors keep their code", async () => {
|
|
320
|
+
routes({ "POST /api/billing/promo/redeem": () => reply(400, { error: "PROMO_TRIAL_ONLY", message: "Promo codes work only before your first payment" }) });
|
|
321
|
+
await expect(run(["billing", "promo", "LATE"])).rejects.toMatchObject({ code: "PROMO_TRIAL_ONLY", message: "Promo codes work only before your first payment" });
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("removed commands are gone", async () => {
|
|
325
|
+
for (const args of [["billing", "topup", "20"], ["billing", "auto-topup"], ["billing", "payment-status", "a1"]]) {
|
|
326
|
+
await expect(run(args)).rejects.toMatchObject({ code: "commander.excessArguments" });
|
|
327
|
+
}
|
|
328
|
+
await expect(run(["billing", "payment-methods", "add"])).rejects.toMatchObject({ code: "commander.unknownCommand" });
|
|
329
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe("describePromo", () => {
|
|
334
|
+
test("a code applied to the running trial", () => {
|
|
335
|
+
expect(describePromo({
|
|
336
|
+
status: "applied", code: "ROST", creditCents: 10000, expiresAt: Date.UTC(2026, 10, 7, 12), balanceCents: 0,
|
|
337
|
+
trialDays: 31, trialCreditCents: 10000, appliesTo: "current_trial",
|
|
338
|
+
})).toBe("Your trial now runs 31 days with $100 in credits, until Nov 7, 2026.");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("an older server's answers", () => {
|
|
342
|
+
expect(describePromo({ status: "applied", creditCents: 2500, expiresAt: null, balanceCents: 3000 }))
|
|
343
|
+
.toBe("Redeemed $25.00. New balance: $30.00.");
|
|
344
|
+
expect(describePromo({ status: "pending_payment_method", creditCents: 2500, expiresAt: null, balanceCents: 0 }))
|
|
345
|
+
.toBe("Code saved: $25.00 in credits. They unlock when you start your trial in Billing.");
|
|
346
|
+
});
|
|
347
|
+
});
|
|
@@ -253,3 +253,75 @@ describe("metrics service selection", () => {
|
|
|
253
253
|
expect(vi.mocked(table).mock.calls[10][1][0][4]).toBe("2.0 KB/s");
|
|
254
254
|
});
|
|
255
255
|
});
|
|
256
|
+
|
|
257
|
+
describe("metrics --cost", () => {
|
|
258
|
+
const live = {
|
|
259
|
+
resources: [{ projectId: "project-test", type: "app", name: "web", vcpu: 0.5, memoryGb: 0.5, storageGb: 0, costPerHour: 0.01 }],
|
|
260
|
+
costPerHour: 0.02,
|
|
261
|
+
};
|
|
262
|
+
const prices = { cpuPerVcpuPerSec: 0.000005, memoryPerGbPerSec: 0.000002, storagePerGbPerSec: 0, egressPerGb: 0.05 };
|
|
263
|
+
const summary = {
|
|
264
|
+
projects: [{ projectId: "project-test", cpuVcpuSeconds: 3600, memoryGbSeconds: 3600, storageGbSeconds: 0, egressBytes: 0, costUsd: 0.03 }],
|
|
265
|
+
periodStart: Date.now() - 86_400_000,
|
|
266
|
+
periodEnd: Date.now() + 86_400_000,
|
|
267
|
+
prices,
|
|
268
|
+
};
|
|
269
|
+
const period = { start: 1, end: 2, includedCents: 1900, usedCents: 3140, overageCents: 1240 };
|
|
270
|
+
|
|
271
|
+
function mockCost(subscription: unknown) {
|
|
272
|
+
vi.mocked(api.get).mockImplementation(async (path) => {
|
|
273
|
+
const p = String(path);
|
|
274
|
+
if (p.startsWith("/api/billing/live")) return live;
|
|
275
|
+
if (p.startsWith("/api/billing/summary")) return summary;
|
|
276
|
+
if (p.startsWith("/api/billing/account")) return { status: "active" };
|
|
277
|
+
if (p.startsWith("/api/billing/subscription")) {
|
|
278
|
+
if (subscription instanceof Error) throw subscription;
|
|
279
|
+
return subscription;
|
|
280
|
+
}
|
|
281
|
+
return { apps: [], addons: [] };
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
beforeEach(() => {
|
|
286
|
+
vi.resetAllMocks();
|
|
287
|
+
vi.mocked(getProjectLink).mockReturnValue({ projectId: "project-test", projectName: "test" });
|
|
288
|
+
vi.mocked(resolveProjectScope).mockResolvedValue({ projectId: "project-test", scope: { workspaceId: "workspace-test" } });
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("shows Pro credits used of $19 and the overage", async () => {
|
|
292
|
+
vi.mocked(isJSONMode).mockReturnValue(false);
|
|
293
|
+
const lines: string[] = [];
|
|
294
|
+
vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => { lines.push(a.join(" ")); });
|
|
295
|
+
mockCost({ plan: "pro", status: "active", period: { kind: "paid", ...period } });
|
|
296
|
+
await run(["--cost"]);
|
|
297
|
+
expect(api.get).toHaveBeenCalledWith("/api/billing/subscription?workspaceId=workspace-test");
|
|
298
|
+
expect(lines.join("\n").replace(/\x1b\[[0-9;]*m/g, "")).toContain("Pro $31.40 of $19 in monthly credits used, overage $12.40");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("shows trial credits left during the trial", async () => {
|
|
302
|
+
vi.mocked(isJSONMode).mockReturnValue(false);
|
|
303
|
+
const lines: string[] = [];
|
|
304
|
+
vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => { lines.push(a.join(" ")); });
|
|
305
|
+
mockCost({ plan: "pro", status: "trialing", period: { ...period, kind: "trial", includedCents: 500, usedCents: 120, overageCents: 0 } });
|
|
306
|
+
await run(["--cost"]);
|
|
307
|
+
expect(lines.join("\n").replace(/\x1b\[[0-9;]*m/g, "")).toContain("Pro trial $1.20 of $5 in trial credits used, $3.80 left");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("adds proCredits to JSON for Pro, and nothing for prepaid credits or older servers", async () => {
|
|
311
|
+
vi.mocked(isJSONMode).mockReturnValue(true);
|
|
312
|
+
mockCost({ plan: "pro", status: "active", period: { kind: "paid", ...period } });
|
|
313
|
+
await run(["--cost"]);
|
|
314
|
+
expect(vi.mocked(printJSON).mock.calls[0][0]).toMatchObject({
|
|
315
|
+
proCredits: { status: "active", kind: "paid", periodStart: 1, periodEnd: 2, includedCents: 1900, usedCents: 3140, overageCents: 1240 },
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
for (const sub of [{ plan: "payg", status: "none", period: null }, new Error("404")]) {
|
|
319
|
+
vi.mocked(printJSON).mockClear();
|
|
320
|
+
mockCost(sub);
|
|
321
|
+
await run(["--cost"]);
|
|
322
|
+
const json = vi.mocked(printJSON).mock.calls[0][0] as Record<string, unknown>;
|
|
323
|
+
expect(json).not.toHaveProperty("proCredits");
|
|
324
|
+
expect(json).toMatchObject({ projectId: "project-test", workspaceCostPerHour: 0.02 });
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
});
|