@jun/soul/billing
Billing
Cobros por periodo: el cliente paga N dias de plan. Riel Chile via Flow.
Que resuelve#
Cobrar de verdad: llevar al cliente a la pasarela, recibir la confirmacion, y dejar su plan
activo hasta una fecha. El nucleo no sabe nada de Flow ni de ningun proveedor — todo eso vive
detras de la interfaz BillingProvider, asi que agregar otra pasarela es escribir un
adaptador, no tocar el modulo.
El modelo: pago por periodo#
El cliente paga N dias de plan. No hay tarjeta guardada ni cobro automatico: renovar es volver a pagar. El periodo se extiende desde el vencimiento vigente, no desde hoy, para que pagar antes de tiempo no le regale dias a la casa.
Montarlo#
import { createBilling, flowProvider } from "@jun/soul/billing";
export const billing = createBilling({
// FABRICA, no instancia: en Workers los secrets solo existen dentro de una
// request. Construir el provider al montar el modulo los deja en undefined.
provider: (env) => flowProvider({
apiKey: env.FLOW_API_KEY,
secretKey: env.FLOW_SECRET_KEY, // secret, nunca en wrangler.jsonc
sandbox: env.FLOW_SANDBOX !== "false",
}),
offers: [
{ plan: "pro", label: "Pro mensual", amount: 9990, currency: "CLP", periodDays: 30 },
{ plan: "pro", label: "Pro anual", amount: 99900, currency: "CLP", periodDays: 365 },
],
freePlan: "free",
// appUrl sale de env.APP_URL si no lo declaras.
errorPage,
layout: (c, opts) => shell({ title: opts.title, body: opts.content }),
});
// en src/index.ts — EL ORDEN IMPORTA
app.route("/api/billing/confirm", billing.webhook); // publico, sin sesion
const portal = new Hono();
portal.use("*", tenancy.requireSessionWithOrg());
portal.route("/", billing.portal);
app.route("/billing", portal);
API#
createBilling(config)- Devuelve
{ portal, webhook, basePath, confirmPath }. offerId(offer)- Id estable de una oferta:
offer.id ?? plan:periodDays. flowProvider({ apiKey, secretKey, sandbox?, paymentMethod? })- Adaptador de Flow.cl.
startCheckout(db, provider, opts)- Crea el intento y devuelve a donde mandar al pagador.
applyConfirmation(db, result)- Aplica una confirmacion. Idempotente.
extendSubscription(db, opts)- Extiende el periodo y sincroniza
orgs.plan. subscriptionFor(db, orgId)- La suscripcion de una org, o null.
isActive(sub)- true si el periodo pagado sigue vigente.
expireSubscriptions(db, freePlan)- Baja a free las vencidas. Va en el cron.
El cron es obligatorio#
// sin esto, el plan pagado NO caduca nunca
export default {
async scheduled(_event, env) {
await expireSubscriptions(env.DB, "free");
},
};
Migracion#
npx soul sync-migrations && npx wrangler d1 migrations apply mi-db --local
Como funciona Flow por dentro#
Flow avisa con un POST a tu urlConfirmation que solo trae un
token. Ese aviso no viene firmado y no dice si se pago ni cuanto: el estado real hay que
ir a preguntarlo con payment/getStatus.
Los tres puntos donde se pierde plata#
1. El monto nunca sale del formulario. El form manda el id de la oferta, no el precio — y tampoco el plan a secas: un mismo plan suele tener mensual y anual, asi que identificar por plan cobraria siempre la primera (el anual saldria al precio del mensual).
const key = String(form.get("offer") ?? form.get("plan") ?? "");
const offer = config.offers.find((o) => offerId(o) === key)
?? config.offers.find((o) => o.plan === key);
if (!offer) return c.html(errorPage("Plan invalido", "Ese plan no existe."), 400);
// el monto sale de la oferta declarada en codigo, no de lo que mando el cliente
2. La activacion es idempotente por construccion. La pasarela puede confirmar el mismo pago varias veces (reintentos, doble callback).
UPDATE payments SET status = 'paid', paid_at = unixepoch()
WHERE commerce_order = ? AND status = 'pending'
RETURNING *
La transicion ocurre dentro de la statement que escribe. Dos callbacks simultaneos: el segundo no encuentra fila que actualizar y no activa nada. Leer el estado y despues escribir seria la misma carrera que costo un bug real en licet.
3. Si no se puede consultar a la pasarela, se responde 500.
const result = await config.provider.resolveConfirmation(c.req.raw);
if (!result) return c.text("unresolved", 500); // que la pasarela REINTENTE
Activar el plan toca dos tablas#
INSERT INTO subscriptions (org_id, provider, external_id, plan, status, current_period_end)
VALUES (?, ?, ?, ?, 'active', unixepoch() + ?)
ON CONFLICT(org_id) DO UPDATE SET
current_period_end = MAX(COALESCE(subscriptions.current_period_end, 0), unixepoch()) + ?
Y despues UPDATE orgs SET plan = ?. Los limites de plan se evaluan contra
orgs.plan: si solo escribieras subscriptions, el cliente pagaria y
seguiria con los limites del plan gratis. extendSubscription hace las dos.
Probarlo sin salir a internet#
const fakeProvider: BillingProvider = {
name: "fake",
async createCheckout(req) {
return { redirectUrl: "https://pasarela.test/pay", externalId: "FLOW-1" };
},
async resolveConfirmation(request) {
const form = await request.formData();
return {
commerceOrder: String(form.get("commerceOrder")),
status: "paid", externalId: "FLOW-1", amount: 9990, currency: "CLP",
};
},
};