Cargando…

Esto está tardando más de lo esperado.

Volver al centro de ayuda

Límites de plan y consumo

Declara qué puede encender un plan y cuánto de algo permite, fija los números por plan y hazlos valer en tu propio código.

Un plan vende dos tipos de cosas: capacidades, que están encendidas o apagadas, y límites, que son números. Ambas se declaran en código y se les pone precio en el panel de administración. Esta guía cubre las dos clases que escribes, las pantallas que alimentan, cómo se calcula el total de una cuenta y la verificación que agregas antes de crear un registro.

Tres palabras

Palabra Qué es Dónde vive
Capacidad (feature) Un sí o no: ¿esta cuenta tiene white_label? Se declara como método de App\Classes\PlanFeatures. Se guarda como feature_key en un complemento. La responde la cuenta de facturación.
Entitlement Una razón por la que una cuenta tiene una capacidad: la activó gratis, está en prueba o la compró de por vida. Una fila en billing_entitlements. La inclusión en un plan y las líneas de la suscripción no son filas; se leen en vivo.
Límite Un número: cuántas activities puede tener esta cuenta. Se declara como método de App\Classes\PlanLimits. Cada plan y cada complemento guarda un valor para él en billing_limits.

Una capacidad se resuelve a partir de todas las formas en que pudo llegar; un límite es una suma. El kit no hace valer ninguno de los dos sobre tus propios registros: tú preguntas antes de crear.

Capacidades

Declara la primera

app/Classes/PlanFeatures.php extiende App\Classes\BasePlanFeatures. Un método público por capacidad. El nombre del método es la clave que el administrador elige al crear un complemento, y el valor de retorno dice si la capacidad ya está liberada en código, así que puedes vender algo en preventa que todavía no encienda nada.

Weblabor Base entrega la clase sin métodos a propósito: una instalación limpia no tiene capacidades propias, así que el formulario de complementos no muestra el campo Capability y el catálogo está vacío hasta que tu proyecto declara una. Agrega la primera así:

namespace App\Classes;

class PlanFeatures extends BasePlanFeatures
{
    public function advanced_reports()
    {
        return true;
    }

    public function white_label()
    {
        return false;   // en preventa, todavía nada lo lee
    }
}

Usa snake case: la clave se guarda tal como la escribes y las etiquetas se derivan de ella.

BasePlanFeatures lee la clase por reflexión, así que no hay ninguna lista que mantener:

Método Devuelve
PlanFeatures::availableFeatures() Cada método público no estático salvo el constructor: ['advanced_reports', 'white_label'].
PlanFeatures::featureOptions() Clave a etiqueta mediante Str::headline: ['advanced_reports' => 'Advanced Reports', ...]. Las opciones del select Capability.
PlanFeatures::isReleased('white_label') true solo cuando la clave está declarada y su método devuelve un valor verdadero.

La clase está conectada en config/billing-core.php bajo user_adapter.plan_features. Solo cambias esa clave cuando mueves la clase.

Pregunta si una cuenta tiene una

La cuenta de facturación es lo único que conoce todas las fuentes, así que pregúntale a ella:

$account = $user->billingAccount;

$account->hasAddOn('advanced_reports');      // true o false
$account->addOnSource('advanced_reports');   // 'lifetime', 'plan', 'subscription', 'trial', 'free' o null
$account->addOnQuantity('extra_seats');      // unidades que tiene, 0 cuando no la tiene

El usuario hace la misma pregunta en una línea, que es lo que normalmente escribe el código de la aplicación:

$user->hasAddOn('advanced_reports');         // true o false
$user->addOnSource('advanced_reports');      // el origen más fuerte, o null

Hay una sola forma de preguntar, y es ésta. $user->hasLockedAddOn() es otra pregunta: responde false para una capacidad otorgada gratis, porque existe para decirle al panel interno cuándo pintar un candado, así que condicionar una pantalla con ella se la esconde a quien recibió la capacidad de regalo.

App\Traits\HasFeatures, que usa App\Models\User, convierte una columna JSON features en arreglo y ofrece enableFeature($key) y disableFeature($key). Es el lado de escritura de una caché, no un lugar del que se lea una decisión: la cuenta la reescribe después de cada otorgamiento, revocación y webhook de Stripe mediante refreshFeatureCache(), en billing_accounts.features y, cuando la tabla del propietario tiene una columna features, también en el propietario. La tabla users de Weblabor Base no tiene esa columna, así que ahí no se escribe nada. Agrega la columna cuando tu producto necesite la caché en el usuario. Las cinco fuentes, las columnas de billing_entitlements y grantAddOn() están en Vende complementos.

Límites

Declara un medidor

app/Classes/PlanLimits.php extiende App\Classes\BasePlanLimits. Un método público por límite: el nombre del método es la clave del límite y el método devuelve cuánto ha consumido el usuario con sesión iniciada. Un segundo método con el mismo nombre más Daily, que recibe una fecha from y una to, devuelve la serie diaria que dibuja la gráfica de consumo. El kit trae un ejemplo resuelto:

namespace App\Classes;

use Carbon\Carbon;

class PlanLimits extends BasePlanLimits
{
    public function activities()
    {
        return auth()->user()->activities()->count();
    }

    public function activitiesDaily(Carbon $from, Carbon $to)
    {
        return auth()->user()->activities()
            ->whereBetween('created_at', [$from, $to])
            ->selectRaw('DATE(created_at) as date, COUNT(*) as count')
            ->groupByRaw('DATE(created_at)')
            ->pluck('count', 'date');
    }
}

Dos cosas que conservar del ejemplo. El medidor no recibe argumentos: para el adaptador de usuario el paquete lo llama sin ninguno, así que mide a auth()->user(). Y el método diario devuelve una colección con la fecha Y-m-d como clave y el conteo como valor; los días sin filas simplemente no aparecen.

El nombre del método es la clave carácter por carácter, guiones bajos incluidos. El kit trae además devices y disk_space, y es en la segunda donde la regla muerde: un método llamado diskSpace no es la clave disk_space, así que la reflexión de abajo nunca lo lista y el medidor que lleva esa clave nunca lo encuentra. El límite marca cero para todas las cuentas, nada de lo que dependa de él se enciende jamás, y en ningún lado aparece un error que lo explique. Escribe el nombre del método exactamente como está escrita la clave.

Un método que mide a alguien distinto de quien está firmado recibe al dueño como argumento opcional, para que un administrador que lee la ficha de un cliente vea la cifra del cliente y no la suya:

public function disk_space($owner = null)
{
    $owner = $owner ?? auth()->user();
    if (! $owner) {
        return 0;
    }
    return intdiv(app(SpaceUsage::class)->userFilesBytes($owner), static::diskSpaceUnitBytes());
}

Ese mismo muestra qué hacer cuando tu cifra y la cuota no están en la misma unidad. La cuota se captura en la unidad del espacio en disco —gigabytes, o megabytes cuando tu PlanLimits declara protected static string $diskSpaceUnit = 'megabyte';—, el conteo llega en bytes, y la comparación se hace con números enteros usando >=, así que el consumo se redondea hacia abajo. Redondear hacia arriba haría que un solo byte contara como una unidad completa y dejaría a una cuenta casi vacía a un paso de su techo.

BasePlanLimits lee la clase por reflexión:

Método Devuelve
PlanLimits::availableLimits() Cada método público no estático salvo el constructor, dailyUsage y cualquier nombre que termine en Daily: ['activities', 'devices', 'disk_space'].
PlanLimits::limitOptions() Las mismas claves, como opciones del select Limit Key en el panel de administración. La clave se muestra tal como está escrita.
PlanLimits::releasableLimits() Las claves que el cliente puede bajar por su cuenta: ['devices', 'disk_space']. Ver abajo.
PlanLimits::preciseUsage($key, $owner) El consumo exacto como decimal cuando la clave responde, null en otro caso. Ver abajo.
PlanLimits::limitModules() El módulo al que pertenece cada límite, como clave de límite a clave de complemento. Vacío por omisión. Ver abajo.
PlanLimits::isVisibleFor($key, $owner) Si el panel de consumo le muestra ese límite a ese dueño: true cuando la clave no declara módulo; si lo declara, si el dueño tiene el módulo.
app(PlanLimits::class)->dailyUsage('activities', $from, $to) Llama a activitiesDaily(). Una colección vacía cuando no existe el método Daily, así que la gráfica sale plana, no rota.

La clase está conectada en config/billing-core.php bajo user_adapter.plan_limits. Un límite que ningún plan usa es inofensivo: se muestra como ilimitado.

Di qué puede liberar el cliente

Un medidor de capacidad mide un nivel que sube y baja, y la tarjeta le dice al cliente qué hacer cuando está lleno. Lo que puede ofrecerle depende de la clave:

public static function releasableLimits(): array
{
    return ['devices', 'disk_space'];
}

Una clave que esté ahí es una que el cliente puede bajar sin pagar —un dispositivo se desconecta desde su perfil, un archivo se borra desde la biblioteca multimedia—, así que la tarjeta le ofrece las dos salidas: liberar algo o comprar más capacidad. A una clave que no esté sólo se le ofrece la compra. Por omisión la lista está vacía, porque medir algo que sólo crece y luego decirle a alguien que libere espacio lo manda a buscar un control que no existe. El kit deja fuera a propósito activities, que sólo crece, y deja dentro devices y disk_space, que no.

La capacidad en sí está descrita en /help/meter-usage-and-packages.

Ata un límite a su módulo

Un límite que sólo significa algo con un módulo —branches en un producto que vende las sucursales como complemento— mostraría si no una tarjeta "0 / 1" a todas las cuentas, incluidas las que nunca compraron el módulo. Di a qué clave de complemento pertenece cada uno de esos límites:

public static function limitModules(): array
{
    return ['branches' => 'branches'];
}

El panel de consumo dibuja entonces esa tarjeta sólo a la cuenta que tiene el módulo, preguntado de la única forma en que lo pregunta el kit, $user->hasAddOn('branches'), así que cuenta el módulo sin importar cómo llegó: de por vida, plan, suscripción, prueba o gratis. Una cuenta sin él no ve tarjeta ni gráficas de ese límite, aunque el límite se elija directamente. Un módulo declarado pero aún no liberado en PlanFeatures sigue la misma respuesta.

Una clave que no esté ahí se le muestra a todos, como antes, sin preguntar nada sobre módulos. El método es estático, así que no es un límite: availableLimits(), el select Limit Key del panel de administración y la lista de cadenas de traducción enumeran las mismas claves que antes, y el administrador sigue fijando todos los límites en cada plan y complemento. Sólo lo lee el panel de consumo; la comprobación antes de crear un registro sigue siendo tuya.

Cuando el número entero es demasiado grueso

Un límite que se compara con números enteros pierde todo lo que está debajo de la unidad, y eso está bien para el techo pero mal para un aviso: una cuenta con una cuota de un gigabyte redondea a cero hasta el momento en que se llena, así que un aviso construido sobre esa cifra no aparecería nunca. Declara la cifra exacta junto a la redondeada:

public static function preciseUsage(string $key, $owner = null): ?float
{
    $owner = $owner ?? auth()->user();
    if ($key !== 'disk_space' || ! $owner) {
        return null;
    }
    return app(SpaceUsage::class)->userFilesBytes($owner) / static::diskSpaceUnitBytes();
}

Las tarjetas de consumo la prefieren cuando una clave responde y vuelven al conteo ordinario cuando devuelve null, así que declararla para una clave no cambia nada para las demás. El techo sigue usando el número redondeado: la cifra que bloquea y la cifra que avisa tienen permiso de ser distintas, y aquí tienen que serlo.

Fija el valor por plan

Cada plan y cada complemento tiene una tarjeta Limits en su formulario, en /admin/billing-plans/{id}/edit y /admin/billing-addons/{id}/edit. Add Limit agrega una fila:

Campo Regla
Limit Key Un select sobre PlanLimits::limitOptions(). Cuando el Context del formulario es User, las opciones salen de billing-core.user_adapter.plan_limits; Team, de billing-team.plan_limits; Both las combina. Cada clave puede aparecer una sola vez por plan: "Each limit key can only be used once."
Limit Value Un entero, al menos 0. En un plan, como máximo 999999999.

El bloque Limits del formulario del plan

Guardar escribe las filas en billing_limits (limitable_type, limitable_id, limit_key, limit_value, con soft deletes): las filas que quitaste se borran, las existentes se actualizan y las nuevas se crean. Nada se envía a Stripe por un límite; solo los precios viajan.

Un valor de 0 es un límite real de cero, no "ilimitado". Para dejar un límite abierto, no agregues la fila.

Qué recibe una cuenta

$user->getLimitTotal('activities') recorre la suscripción default de la cuenta:

límite del plan para la clave
+ límites de los complementos que son líneas de la suscripción
+ límites de los complementos que el plan incluye y que no son ya líneas

Devuelve null cuando ninguno de los tres define la clave, y la pantalla muestra ∞. Los complementos que se tienen como entitlements free, trial o lifetime no suman a un límite.

Cuando los planes no están activos la respuesta es null para todos, con suscripción o sin ella: la pregunta "¿están activos los planes?" se hace antes de siquiera buscar la suscripción, así que un proyecto con la bandera apagada, sin STRIPE_SECRET o sin ningún plan creado todavía no tiene ningún límite, en lugar de tener límites que nadie puede ver. Ojo con esas dos últimas condiciones: pesan tanto como la bandera, y encenderla sin llave de pagos deja todos los límites abiertos igual.

Mientras los planes están activos, una cuenta sin suscripción activa —sin ninguna, o con una que ya terminó— responde 0, así que está por encima de todos los límites y no recibe nada, complementos incluidos. Es intencional: el registro suscribe a todos al plan gratuito, una cuenta cuya suscripción termina vuelve a él, y a un usuario que sigue sin estar en ningún plan hay que mandarlo a /account/plans, que es lo que ya hace el middleware ensure.subscribed para las rutas de /app.

El total se resuelve en el momento en que se pregunta, contra el plan que la suscripción carga en ese instante, y sólo se guarda durante la petición que preguntó. Cambiar de plan le entrega a la cuenta la cuota del plan nuevo de inmediato, sin esperar a que el ciclo cambie, y un valor de límite cambiado en el panel de administración está en vigor en la siguiente pantalla que cargue el suscriptor.

$user->getLimitUsed('activities') llama a tu PlanLimits::activities(); una clave sin método cuenta como 0. $user->reachedLimit('activities') es true cuando el total no es null y lo consumido es mayor o igual que él.

Qué ve el usuario

El dashboard en /app muestra un bloque de consumo cuando los planes están activos: una tarjeta por clave de PlanLimits::availableLimits() que isVisibleFor() deja pasar, con la clave como título (activities se lee "Activities", traducido mediante lang/), el conteo consumido y el total o ∞. Cuando existe un total la tarjeta dibuja una barra de progreso: ámbar, con una línea que dice cuánto se lleva usado, a partir del 80 % del total, y roja, con una línea que dice que se alcanzó el límite, en cuanto se alcanza. La barra lee la cifra precisa cuando la clave declara una, para que en una cuota pequeña aparezca un aviso en vez de saltar de vacía a llena.

Al hacer clic en una tarjeta se abren dos gráficas de ese límite, tomadas de tu método <key>Daily(): una de columnas con el consumo diario y una de línea con el consumo acumulado, sobre un rango From y To que empieza en el mes en curso y se puede cambiar ahí mismo. Otro clic en la tarjeta las cierra. El componente es App\Livewire\Shared\UsageLimits; el dashboard lo incluye como <livewire:shared.usage-limits />.

Las tarjetas de plan en /account/plans muestran las viñetas de texto del plan, no sus límites. La página de detalle del complemento, /account/add-ons/{id}, lista los límites del complemento bajo "What it includes", como el valor seguido de la clave en minúsculas.

Haz valer un límite en tu código

El kit verifica un límite en dos lugares, y los dos son un cambio que la cuenta pide ella misma. Cuando una suscripción se mueve a otro plan (un cambio de plan, o el plan gratuito en el registro), cada límite del plan destino se compara con el consumo actual y el movimiento se rechaza con "You have exceeded the limit for :key (:current / :max)." cuando el consumo está por encima del valor; los límites de los complementos no forman parte de esa comparación. Cuando se baja la cantidad de un complemento vendido por unidad, el total con el que quedaría la cuenta —plan y complementos juntos— se compara igual, y el cambio se rechaza con la misma frase. Ninguno de los dos impide que un usuario cree el registro que se pasa.

disk_space es la única excepción, porque el kit es dueño de la única acción que lo sube: una subida se rechaza cuando la cuenta está en su cuota o por encima de ella, venga de la pantalla que venga —una cuota de 0 incluida, y la foto de perfil también—, y al usuario se le dice que se quedó sin espacio y se le ofrecen las dos salidas. La regla detrás: lo que una persona hace y puede repetir se rechaza al llegar al tope, mientras que lo que se perdería si se rechaza, como un webhook entrante, se guarda. Las actividades y los dispositivos se siguen guardando en su tope. Tus propios registros siguen siendo tuyos de cuidar, abajo.

Así que la verificación antes de crear algo es tuya. Pregúntale al usuario antes de escribir:

use Livewire\Component;
use WireUi\Traits\WireUiActions;

class CreateActivity extends Component
{
    use WireUiActions;

    public function save()
    {
        if (auth()->user()->reachedLimit('activities')) {
            $this->notification()->error(
                title: __('Limit reached'),
                description: __('Your plan allows :max activities. Upgrade to add more.', [
                    'max' => auth()->user()->getLimitTotal('activities'),
                ])
            );
            return;
        }

        // crea el registro
    }
}

Usa reachedLimit() en lugar de comparar los números por tu cuenta, para que null siga siendo ilimitado. Muestra el total y un camino a la página de planes, /account/plans, en vez de un rechazo a secas, y pon la frase en lang/ como cualquier otro texto. Cuando el registro lo crea algo distinto del usuario con sesión, un job o un webhook, recuerda que el medidor lee auth()->user(): ahí mide con tu propia consulta en lugar de llamar a PlanLimits.