soul v4.6.0

@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#

TypeScript
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 }),
});
TypeScript
// 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#

TypeScript
// sin esto, el plan pagado NO caduca nunca
export default {
  async scheduled(_event, env) {
    await expireSubscriptions(env.DB, "free");
  },
};

Migracion#

Terminal
npx soul sync-migrations && npx wrangler d1 migrations apply mi-db --local