Si construiste un SaaS con bases de datos separadas por tenant, ya resolviste el problema de aislamiento. El siguiente problema es cobrarle a cada tenant de acuerdo a un plan, gestionar el ciclo de vida de su suscripción y restringir funcionalidades según lo que contrataron. Esa capa de lógica vive en la base de datos central, no en la de cada tenant, y tiene sus propias complejidades.
Este artículo asume que ya tenés la arquitectura multitenant funcionando con stancl/tenancy y Laravel. Si no, el artículo anterior de esta serie cubre esa parte.
Dónde vive la lógica de facturación
La primera decisión importante es entender que la suscripción de un tenant es un concepto de la base de datos central, no de la base de datos del tenant. Tiene sentido: el plan de un cliente existe independientemente de sus datos operativos, y necesitás acceder a él antes de inicializar el contexto del tenant.
El modelo central queda así:
// tenants (tabla central)
id | plan | estado_suscripcion | trial_ends_at
------------|-------------|-------------------|---------------
garcia-law | profesional | activo | null
lopez-cons | basico | trial | 2025-09-15
nueva-firma | profesional | inactivo | null
El estado_suscripcion controla el acceso al producto. El plan determina qué funcionalidades están disponibles. Ambos campos se leen desde la base central antes de que cualquier request llegue al contexto del tenant.
Integración con Stripe
Para la mayoría de los SaaS, Stripe es la elección correcta para procesar pagos y manejar suscripciones recurrentes. El paquete Laravel Cashier lo integra directamente con Eloquent.
$ composer require laravel/cashier $ php artisan vendor:publish --tag="cashier-migrations" $ php artisan migrate
Cashier agrega las columnas necesarias al modelo Tenant: stripe_id, pm_type, pm_last_four, trial_ends_at y subscriptions. La implementación en el modelo central es directa:
use Laravel\Cashier\Billable; class Tenant extends BaseTenant implements TenantWithDatabase { use HasDatabase, HasDomains, Billable; public static function getCustomColumns(): array { return [ 'id', 'plan', 'activo', 'razon_social', 'stripe_id', 'pm_type', 'pm_last_four', 'trial_ends_at', ]; } }
Lógica de trial
El período de prueba gratuito es un patrón estándar en SaaS. La implementación correcta tiene tres características: el tenant puede usar el producto completo durante el trial, el sistema sabe cuándo vence, y el acceso se corta automáticamente al vencer sin intervención manual.
public function handle(): void { $tenant = Tenant::create([ 'id' => $this->slug, 'plan' => 'profesional', 'trial_ends_at' => now()->addDays(14), ]); $tenant->domains()->create([ 'domain' => $this->slug . '.' . config('app.domain') ]); $tenant->run(function () { Artisan::call('migrate', [ '--path' => 'database/migrations/tenant', '--force' => true, ]); }); }
Para verificar el estado del trial en cualquier punto del ciclo de vida de un request, Cashier expone los métodos onTrial() y onGenericTrial(). Con eso se puede construir un middleware que bloquee el acceso cuando el trial venció y no hay suscripción activa.
public function handle($request, $next) { $tenant = tenant(); // Trial activo o suscripción vigente: acceso permitido if ($tenant->onTrial() || $tenant->subscribed('default')) { return $next($request); } // Sin acceso: redirigir a pantalla de upgrade return redirect()->route('billing.upgrade'); }
Webhooks de Stripe
La parte más crítica del sistema de facturación no es el checkout: es el manejo de webhooks. Stripe notifica al sistema sobre cada evento relevante del ciclo de vida de una suscripción — pagos exitosos, pagos fallidos, cancelaciones, upgrades. Si el sistema no procesa esos eventos correctamente, el estado de la suscripción en la base de datos queda desincronizado con la realidad.
Un sistema de facturación que no maneja correctamente los webhooks es un sistema que eventualmente va a dar acceso a clientes que no pagaron, o va a cortar acceso a clientes que sí lo hicieron.
Cashier registra automáticamente una ruta para recibir webhooks de Stripe en /stripe/webhook. Lo que hay que configurar es qué hacer con cada evento. Los eventos mínimos que cualquier SaaS debe manejar son:
- customer.subscription.updated — el plan cambió, la suscripción se renovó o se pausó.
- customer.subscription.deleted — la suscripción fue cancelada. Hay que decidir si el acceso se corta inmediatamente o al final del período pagado.
- invoice.payment_failed — un cobro falló. El sistema debe iniciar el flujo de recuperación: notificar al cliente, dar un período de gracia y eventualmente suspender el acceso.
- invoice.payment_succeeded — confirmación de pago exitoso. El acceso debe extenderse.
use Laravel\Cashier\Http\Controllers\WebhookController as CashierWebhook; class WebhookController extends CashierWebhook { public function handleCustomerSubscriptionDeleted(array $payload): void { $stripeId = $payload['data']['object']['customer']; $tenant = Tenant::where('stripe_id', $stripeId)->first(); if ($tenant) { $tenant->update(['activo' => false]); // Notificar al administrador del tenant Notification::send($tenant->adminUser(), new SuscripcionCancelada()); } } public function handleInvoicePaymentFailed(array $payload): void { $stripeId = $payload['data']['object']['customer']; $tenant = Tenant::where('stripe_id', $stripeId)->first(); if ($tenant) { // Período de gracia de 3 días antes de suspender $tenant->update([ 'grace_ends_at' => now()->addDays(3), ]); } } }
Upgrades y downgrades de plan
Cuando un tenant cambia de plan, hay dos cosas que deben pasar: Stripe actualiza la suscripción (y calcula el prorate automáticamente), y el sistema actualiza la columna plan en la base central para que los gates de funcionalidades reflejen el nuevo estado.
public function execute(Tenant $tenant, string $nuevoPlan): void { $priceId = config("plans.{$nuevoPlan}.stripe_price_id"); // Actualizar en Stripe (con prorate automático) $tenant->subscription('default')->swap($priceId); // Sincronizar en la base central $tenant->update(['plan' => $nuevoPlan]); }
Para controlar el acceso a funcionalidades según el plan, el enfoque más limpio es usar Gates de Laravel, definidos en el contexto central antes de inicializar el tenant:
Gate::define('usar-reportes-avanzados', function () { return in_array(tenant()->plan, ['profesional', 'enterprise']); }); Gate::define('usuarios-ilimitados', function () { return tenant()->plan === 'enterprise'; });
Lo que no debe faltar en producción
Antes de poner el sistema de facturación en producción, hay tres cosas que no son negociables:
- Firma de webhooks verificada. Cashier lo hace automáticamente si configurás
STRIPE_WEBHOOK_SECRETen el.env. Sin esto, cualquiera puede enviar eventos falsos a tu endpoint. - Idempotencia en el procesamiento de webhooks. Stripe puede enviar el mismo evento más de una vez. El handler debe verificar si el evento ya fue procesado antes de ejecutar la lógica de negocio.
- Logs de facturación separados. Los eventos de suscripción deben loguearse en una tabla propia para poder auditar qué pasó en caso de disputas o errores de cobro.
El sistema de facturación es el único componente del SaaS cuyo mal funcionamiento tiene consecuencias legales y financieras directas. No es el lugar para tomar atajos.