openclaw/skills/lemonsqueezy/scripts/cli.ts
kiranjd 7e784d6a88 feat(skills): add ga4 and lemonsqueezy CLI skills
- ga4: Query GA4 analytics - users, events, funnels, transcriptions
- lemonsqueezy: Query revenue, orders, subscriptions, customers

Both use bun scripts and require env vars for auth.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 21:43:59 +05:30

396 lines
12 KiB
TypeScript

#!/usr/bin/env bun
/**
* LemonSqueezy CLI - Revenue, orders, subscriptions at your fingertips
* Usage: bun skills/lemonsqueezy/cli.ts <command> [options]
*/
const API_KEY = process.env.LEMONSQUEEZY_API_KEY;
const BASE_URL = "https://api.lemonsqueezy.com/v1";
if (!API_KEY) {
console.error("Error: LEMONSQUEEZY_API_KEY environment variable required");
console.error("Set it in your shell or source from SECRETS.env");
process.exit(1);
}
async function api(endpoint: string, params: Record<string, string> = {}) {
const url = new URL(`${BASE_URL}${endpoint}`);
Object.entries(params).forEach(([k, v]) => {
if (v) url.searchParams.append(k, v);
});
const res = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${API_KEY}`,
Accept: "application/vnd.api+json",
},
});
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`);
}
return res.json();
}
function cents(n: number): string {
return `$${(n / 100).toFixed(2)}`;
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
// Commands
async function overview() {
const { data } = await api("/stores");
const store = data[0]?.attributes;
if (!store) {
console.log("No stores found");
return;
}
console.log(`📊 ${store.name} Revenue Overview\n`);
console.log(`Total Revenue: ${cents(store.total_revenue)}`);
console.log(`Total Sales: ${store.total_sales}`);
console.log(`30-Day Revenue: ${cents(store.thirty_day_revenue)}`);
console.log(`30-Day Sales: ${store.thirty_day_sales}`);
}
async function orders(limit = 20, status?: string, from?: string, to?: string) {
const params: Record<string, string> = { "page[size]": String(limit) };
if (status) params["filter[status]"] = status;
const { data } = await api("/orders", params);
// Filter by date if provided
let filtered = data;
if (from || to) {
const fromDate = from ? new Date(from) : new Date(0);
const toDate = to ? new Date(to) : new Date();
toDate.setHours(23, 59, 59, 999); // End of day
filtered = data.filter((order: any) => {
const created = new Date(order.attributes.created_at);
return created >= fromDate && created <= toDate;
});
}
const dateRange = from || to ? ` (${from || "start"} to ${to || "now"})` : "";
console.log(`📦 Orders${dateRange} (${filtered.length})\n`);
// Group by week for summary if date range specified
if (from && to) {
const byWeek: Record<string, { count: number; revenue: number }> = {};
for (const order of filtered) {
const a = order.attributes;
const date = new Date(a.created_at);
const week = getWeekNumber(date);
const key = `${date.getFullYear()}-W${week.toString().padStart(2, "0")}`;
if (!byWeek[key]) byWeek[key] = { count: 0, revenue: 0 };
byWeek[key].count++;
byWeek[key].revenue += parseInt(a.total_usd || 0) / 100;
}
console.log("Week".padEnd(10) + "Orders".padStart(8) + "Revenue".padStart(12));
console.log("-".repeat(30));
const weeks = Object.entries(byWeek).sort(([a], [b]) => a.localeCompare(b));
for (const [week, stats] of weeks) {
console.log(
week.padEnd(10) +
stats.count.toString().padStart(8) +
`$${stats.revenue.toFixed(2)}`.padStart(12)
);
}
console.log();
}
// Individual orders
for (const order of filtered) {
const a = order.attributes;
const statusIcon = a.status === "paid" ? "✓" : a.status === "refunded" ? "↩" : "○";
console.log(
`${statusIcon} #${a.order_number} | ${a.total_formatted.padEnd(10)} | ${a.user_email} | ${formatDate(a.created_at)}`
);
}
}
function getWeekNumber(date: Date): number {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
}
async function orderLookup(query: string) {
// Try by order number first, then by email
let orders: any[] = [];
if (query.includes("@")) {
const { data } = await api("/orders", { "filter[user_email]": query });
orders = data;
} else {
const { data } = await api("/orders");
orders = data.filter(
(o: any) => o.attributes.order_number.toString() === query
);
}
if (orders.length === 0) {
console.log(`No orders found for: ${query}`);
return;
}
console.log(`🔍 Orders for ${query}\n`);
for (const order of orders) {
const a = order.attributes;
console.log(`Order #${a.order_number}`);
console.log(` Status: ${a.status}${a.refunded ? " (refunded)" : ""}`);
console.log(` Total: ${a.total_formatted}`);
console.log(` Customer: ${a.user_name} <${a.user_email}>`);
console.log(` Date: ${formatDate(a.created_at)}`);
if (a.refunded_at) console.log(` Refunded: ${formatDate(a.refunded_at)}`);
console.log();
}
}
async function subscriptions(status?: string) {
const params: Record<string, string> = { "page[size]": "100" };
if (status) params["filter[status]"] = status;
const { data } = await api("/subscriptions", params);
// Calculate MRR
let mrr = 0;
const byStatus: Record<string, number> = {};
for (const sub of data) {
const s = sub.attributes;
byStatus[s.status] = (byStatus[s.status] || 0) + 1;
if (s.status === "active") {
// Assuming monthly - would need price endpoint for accuracy
mrr += parseInt(s.first_subscription_item?.price || 0) / 100;
}
}
console.log(`💳 Subscriptions (${data.length})\n`);
console.log("By Status:");
for (const [st, count] of Object.entries(byStatus)) {
console.log(` ${st}: ${count}`);
}
console.log(`\nEstimated MRR: $${mrr.toFixed(2)}\n`);
if (data.length > 0) {
console.log("Recent:");
for (const sub of data.slice(0, 10)) {
const s = sub.attributes;
const icon =
s.status === "active" ? "●" : s.status === "cancelled" ? "○" : "◐";
console.log(
` ${icon} ${s.user_email.padEnd(30)} | ${s.status.padEnd(10)} | ${s.product_name}`
);
}
}
}
async function mrr() {
const { data } = await api("/subscriptions", {
"filter[status]": "active",
"page[size]": "100",
});
const byProduct: Record<string, { count: number; mrr: number }> = {};
for (const sub of data) {
const s = sub.attributes;
const key = `${s.product_name} - ${s.variant_name}`;
if (!byProduct[key]) byProduct[key] = { count: 0, mrr: 0 };
byProduct[key].count++;
// Price from first_subscription_item
const price = parseInt(s.first_subscription_item?.price || 0) / 100;
byProduct[key].mrr += price;
}
const sorted = Object.entries(byProduct).sort(
([, a], [, b]) => b.mrr - a.mrr
);
const totalMRR = sorted.reduce((sum, [, v]) => sum + v.mrr, 0);
console.log(`💰 MRR Breakdown\n`);
console.log(`Total MRR: $${totalMRR.toFixed(2)}`);
console.log(`Active Subscribers: ${data.length}\n`);
for (const [product, stats] of sorted) {
console.log(`${product}`);
console.log(` Subscribers: ${stats.count}`);
console.log(` MRR: $${stats.mrr.toFixed(2)}`);
}
}
async function customers(limit = 20) {
const { data } = await api("/customers", { "page[size]": String(limit) });
let totalLTV = 0;
console.log(`👥 Customers (${data.length})\n`);
for (const cust of data) {
const c = cust.attributes;
const ltv = parseInt(c.total_revenue_currency || 0) / 100;
totalLTV += ltv;
console.log(
`${c.email.padEnd(35)} | LTV: $${ltv.toFixed(2).padStart(8)} | ${c.status}`
);
}
console.log(`\nTotal LTV: $${totalLTV.toFixed(2)}`);
}
async function products() {
const { data } = await api("/products");
console.log(`📦 Products (${data.length})\n`);
for (const prod of data) {
const p = prod.attributes;
const statusIcon = p.status === "published" ? "●" : "○";
console.log(`${statusIcon} ${p.name}`);
console.log(` Price: ${p.price_formatted || "varies"}`);
console.log(` URL: ${p.buy_now_url}`);
console.log();
}
}
async function refunds(limit = 20) {
// Fetch all orders and filter for refunded ones client-side
// (LemonSqueezy API filter[refunded] is unreliable)
const { data } = await api("/orders", {
"page[size]": String(Math.min(limit * 5, 100)), // Fetch more to find refunds
});
const refunded = data.filter((o: any) =>
o.attributes.refunded || o.attributes.status === "refunded"
).slice(0, limit);
const total = refunded.reduce(
(sum: number, o: any) => sum + parseInt(o.attributes.total_usd || 0),
0
);
console.log(`↩️ Refunds (${refunded.length})\n`);
console.log(`Total Refunded: ${cents(total)}\n`);
if (refunded.length === 0) {
console.log("No refunds found.");
return;
}
for (const order of refunded) {
const a = order.attributes;
console.log(
`#${a.order_number} | ${a.total_formatted.padEnd(10)} | ${a.user_email} | ${formatDate(a.refunded_at || a.created_at)}`
);
}
}
// Help
function help() {
console.log(`
LemonSqueezy CLI
Usage: ls <command> [options]
Commands:
overview Revenue overview (total + 30-day)
orders [--limit N] Recent orders (default: 20)
orders --status X Filter by status (paid/refunded/pending)
orders --from DATE --to DATE
Orders in date range with week-by-week summary
order <num|email> Lookup by order number or customer email
subs [--status X] Subscriptions list
mrr MRR breakdown by product
customers [--limit N] Customer list with LTV
products All products
refunds [--limit N] Refunded orders
Environment:
LEMONSQUEEZY_API_KEY API key (required)
Examples:
ls overview
ls orders --limit 10
ls orders --from 2025-11-01 --to 2026-01-20
ls order 12345
ls order customer@example.com
ls subs --status active
ls mrr
`);
}
// Main
async function main() {
const args = process.argv.slice(2);
const cmd = args[0];
// Parse flags
const flags: Record<string, string> = {};
for (let i = 1; i < args.length; i++) {
if (args[i].startsWith("--")) {
const key = args[i].slice(2);
flags[key] = args[i + 1] || "true";
i++;
}
}
try {
switch (cmd) {
case "overview":
await overview();
break;
case "orders":
await orders(parseInt(flags.limit) || 100, flags.status, flags.from, flags.to);
break;
case "order":
const query = args[1];
if (!query) {
console.error("Usage: ls order <order_number|email>");
process.exit(1);
}
await orderLookup(query);
break;
case "subs":
case "subscriptions":
await subscriptions(flags.status);
break;
case "mrr":
await mrr();
break;
case "customers":
await customers(parseInt(flags.limit) || 20);
break;
case "products":
await products();
break;
case "refunds":
await refunds(parseInt(flags.limit) || 20);
break;
case "help":
case "--help":
case "-h":
case undefined:
help();
break;
default:
console.error(`Unknown command: ${cmd}`);
help();
process.exit(1);
}
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : err}`);
process.exit(1);
}
}
main();