@jun/soul/plans
Plans
Planes y limites tipados, sin magia y sin dependencias.
Que resuelve#
Define que puede hacer cada plan y cuanto. El plan vive en la organizacion —la unidad que paga— y soul solo se encarga de evaluarlo: no cobra. La pasarela de pago es tuya.
Declarar los planes#
import { definePlans } from "@jun/soul/plans";
export const PLANS = definePlans({
free: { proyectos: 1, miembros: 2, almacenamientoBytes: 50 * 1024 * 1024 },
pro: { proyectos: 20, miembros: 10, almacenamientoBytes: 5 * 1024 * 1024 * 1024 },
founder: { proyectos: Infinity, miembros: Infinity, almacenamientoBytes: 50 * 1024 * 1024 * 1024 },
});
La API#
definePlans(objeto)- Identidad tipada: devuelve el mismo objeto pero con los tipos inferidos, para que autocompletar conozca tus limites.
planFor(plans, nombrePlan)- Resuelve los limites de una organizacion. Si el nombre no existe, cae al primer plan declarado.
hasCapacity(limite, actual)actual < limite. Funciona conInfinity.formatLimit(n)- Para mostrar:
Infinityse convierte en"Ilimitado".
Aplicar un limite#
El patron es siempre el mismo: resolver el plan, contar lo que ya hay, y comparar antes de crear.
import { planFor, hasCapacity, formatLimit } from "@jun/soul/plans";
import { PLANS } from "../config";
proyectos.post("/", tenancy.requirePermission("proyectos.write"), async (c) => {
const limites = planFor(PLANS, c.var.org.plan);
const { n } = (await c.env.DB.prepare(
"SELECT COUNT(*) AS n FROM proyectos WHERE org_id = ?",
).bind(c.var.org.id).first<{ n: number }>())!;
if (!hasCapacity(limites.proyectos, n)) {
return c.html(errorPage(
"Alcanzaste el limite de tu plan",
`Tu plan permite ${formatLimit(limites.proyectos)} proyecto(s). Mejora el plan para crear mas.`,
), 403);
}
// ...crear
});
Mostrar el uso frente al limite#
import { planFor, formatLimit } from "@jun/soul/plans";
function tarjetaDeUso(plan: string, usados: number) {
const limites = planFor(PLANS, plan);
const tope = limites.proyectos;
const pct = tope === Infinity ? 0 : Math.min(100, Math.round((usados / tope) * 100));
return `<div class="soul-card">
<p>Proyectos: <strong>${usados}</strong> de ${formatLimit(tope)}</p>
${tope === Infinity ? "" : `<progress value="${usados}" max="${tope}"></progress>`}
${pct >= 80 ? '<p>Te estas acercando al limite de tu plan.</p>' : ""}
</div>`;
}
Limite de bytes al subir a R2#
subidas.post("/", async (c) => {
const limites = planFor(PLANS, c.var.org.plan);
const file = (await c.req.parseBody())["file"] as File;
const { usados } = (await c.env.DB.prepare(
"SELECT COALESCE(SUM(bytes), 0) AS usados FROM archivos WHERE org_id = ?",
).bind(c.var.org.id).first<{ usados: number }>())!;
if (usados + file.size > limites.almacenamientoBytes) {
return c.html(errorPage("Sin espacio", "Tu plan no tiene espacio suficiente."), 403);
}
await c.env.MEDIA.put(`${c.var.org.id}/${crypto.randomUUID()}`, file.stream());
// ...registrar bytes
});
Funciones activadas por plan#
Los limites no tienen por que ser numeros: un booleano funciona igual de bien.
export const PLANS = definePlans({
free: { proyectos: 1, exportarCsv: false, dominioPropio: false },
pro: { proyectos: 20, exportarCsv: true, dominioPropio: true },
});
app.get("/exportar", async (c) => {
const limites = planFor(PLANS, c.var.org.plan);
if (!limites.exportarCsv) {
return c.html(errorPage("Solo en Pro", "Exportar a CSV esta disponible en el plan Pro."), 403);
}
return c.body(await armarCsv(c), 200, { "Content-Type": "text/csv" });
});
Tabla de precios desde la misma fuente#
Si la pagina de precios se genera desde PLANS, no puede desincronizarse de lo que el
codigo aplica de verdad.
import { formatLimit } from "@jun/soul/plans";
const FILAS = [
["Proyectos", "proyectos"],
["Miembros del equipo", "miembros"],
] as const;
function tablaDePrecios() {
const nombres = Object.keys(PLANS);
const filas = FILAS.map(([etiqueta, key]) =>
`<tr><th>${etiqueta}</th>${nombres
.map((n) => `<td>${formatLimit((PLANS as any)[n][key])}</td>`)
.join("")}</tr>`,
).join("");
return `<table><thead><tr><th></th>${nombres
.map((n) => `<th>${n}</th>`).join("")}</tr></thead><tbody>${filas}</tbody></table>`;
}
Cambiar el plan de una organizacion#
Mientras no haya pasarela de pago, se hace desde el panel de admin o a mano:
npx wrangler d1 execute mi-saas-db --env production --remote \
--command "UPDATE orgs SET plan = 'pro' WHERE slug = 'acme'"
Y cuando conectes el cobro, el webhook del proveedor hace lo mismo:
import { verifyStandardWebhook } from "@jun/soul/webhooks";
pagos.post("/", async (c) => {
const raw = await c.req.text();
if (!(await verifyStandardWebhook(c.env.WEBHOOK_SECRET, c.req.raw.headers, raw))) {
return c.body(null, 401);
}
const evento = JSON.parse(raw);
if (evento.type === "subscription.active") {
await c.env.DB.prepare("UPDATE orgs SET plan = ? WHERE id = ?")
.bind("pro", evento.data.metadata.org_id).run();
}
return c.json({ received: true });
});