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í:

Modelo central (base de datos compartida)
// 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:

app/Models/Tenant.php
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.

Crear tenant con trial de 14 días
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.

app/Http/Middleware/VerificarAccesoTenant.php
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:

app/Http/Controllers/WebhookController.php
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.

app/Actions/CambiarPlanTenant.php
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:

app/Providers/AuthServiceProvider.php
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:

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.