Facturación
arrel/billing es un paquete aparte que añade suscripciones de pago a un panel de Arrel. Trae el modelo de datos (productos, planes, precios, suscripciones, pedidos y descuentos), un contrato de proveedor de pago con un driver para Stripe, el endpoint de los webhooks y una página de facturación que se añade al panel como plugin. Necesita Arrel 1.2 o posterior.
Panel::make('admin')
->plugins([BillingPlugin::make()])La página muestra el plan actual y su estado, los planes disponibles con un botón para suscribirse, un enlace al portal de cliente del proveedor y el historial de pagos.
Instalación
El paquete está en un repositorio privado, como Arrel. Añádelo a composer.json junto al de Arrel; el mismo token de GitHub (Instalación) necesita acceso de lectura:
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/arrelphp/arrel" },
{ "type": "vcs", "url": "https://github.com/arrelphp/billing" }
]
}E instálalo:
composer require arrel/billing
php artisan migrateLas migraciones se ejecutan desde el paquete. Publícalas, o la configuración o los textos, solo si las tienes que cambiar:
php artisan vendor:publish --tag=billing-config
php artisan vendor:publish --tag=billing-migrations
php artisan vendor:publish --tag=billing-translationsEl script y la hoja de estilo de la página los sirve el propio paquete, con un hash del contenido en la URL. No hay que publicar ningún asset al desplegar.
Configuración
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...El resto está en config/billing.php:
| Clave | Por defecto | Descripción |
|---|---|---|
connection | null | Conexión de base de datos de todas las tablas de facturación. En una aplicación multi-tenant, la conexión central. |
ability | billing.manage | La habilidad que se comprueba antes de mostrar la página o de iniciar un pago. |
provider | stripe | El proveedor de pago activo. |
webhook.path | billing/webhooks/{provider} | Donde el proveedor envía los eventos. |
webhook.middleware | ['throttle:300,1'] | Middleware de la ruta del webhook. Va fuera del grupo web: sin sesión ni comprobación CSRF. |
Todas las tablas llevan el prefijo billing_.
Quién paga
El modelo que facturas implementa Billable con el trait HasSubscriptions:
use Arrel\Billing\Concerns\HasSubscriptions;
use Arrel\Billing\Contracts\Billable;
final class Team extends Model implements Billable
{
use HasSubscriptions;
}El trait añade subscriptions(), orders() y currentSubscription(), que es la suscripción creada más recientemente. Las suscripciones y los pedidos son polimórficos y guardan la clave como texto, así que funcionan con claves enteras, UUID o de texto.
Después dile al paquete quién paga en cada petición. Si no lo haces, paga el usuario autenticado, si su modelo es Billable:
use Arrel\Billing\Billing;
Billing::resolveBillableUsing(fn (Request $request): ?Team => $request->user()?->currentTeam);En una aplicación multi-tenant, lo normal es que pague el tenant. Una petición sin nadie que pague recibe un 403.
La página
Registra el plugin en los paneles que deben mostrar la facturación:
Panel::make('admin')
->plugins([
BillingPlugin::make()->sort(90)->group('Configuración'),
])La entrada de navegación solo aparece a los usuarios que tienen la habilidad configurada, y las rutas de la página (/arrel-plugins/billing) comprueban la misma habilidad. Concédela como concedes cualquier otra cosa, con un Gate, una Policy o un permiso:
Gate::define('billing.manage', fn (User $user): bool => $user->is_owner);El pago y el portal de cliente vuelven a la página de facturación del panel desde el que se han abierto. El identificador del panel se comprueba contra los paneles registrados, de modo que el proveedor nunca redirige a una dirección que haya elegido el navegador.
El catálogo
Productos, planes y precios son modelos de Eloquent normales. Créalos como cualquier otro dato, por ejemplo en un seeder:
$product = Product::query()->updateOrCreate(['key' => 'app'], ['name' => 'App']);
$plan = Plan::query()->updateOrCreate(
['key' => 'basic'],
['product_id' => $product->id, 'name' => 'Básico', 'sort_order' => 1],
);
Price::query()->updateOrCreate(
['plan_id' => $plan->id, 'interval' => PriceInterval::Month],
['currency' => 'eur', 'amount' => 2900],
);Los importes son enteros, en céntimos. Un precio solo se puede vender a través de un proveedor cuando está vinculado con el identificador del precio en ese proveedor:
php artisan billing:map-price basic month stripe price_1Q...Iniciar un pago con un precio sin vínculo para el proveedor activo lanza MissingPriceMappingException.
Webhooks
Configura el proveedor para que envíe los eventos a la ruta del webhook, /billing/webhooks/stripe por defecto. El endpoint de Stripe debe usar la versión de la API 2025-03-31.basil o posterior.
El driver de Stripe actúa sobre estos eventos y responde al resto sin guardarlos:
customer.subscription.created,customer.subscription.updatedycustomer.subscription.deletedactualizan la suscripción.invoice.paideinvoice.payment_failedregistran un pedido.
Cada evento se verifica con su firma y se guarda una sola vez por proveedor: un evento repetido no hace nada. Si aplicarlo falla, todo se deshace y el reintento del proveedor se procesa.
El pago guarda el identificador de la suscripción local en los metadatos de la suscripción del proveedor, de modo que cada evento encuentra su suscripción llegue en el orden que llegue. Un estado que el paquete no conoce se guarda como canceled: un estado inesperado nunca da acceso.
Reaccionar a los cambios
SubscriptionUpdated se dispara dentro de la misma transacción que aplica el webhook. Si un listener lanza una excepción, el evento se deshace y el proveedor lo vuelve a enviar:
use Arrel\Billing\Events\SubscriptionUpdated;
final class SyncTeamFeatures
{
public function handle(SubscriptionUpdated $event): void
{
$team = $event->subscription->billable;
$event->subscription->isUsable()
? $team->enableFeatures($event->subscription->plan)
: $team->disableFeatures();
}
}isUsable() es cierto mientras la suscripción está activa, en periodo de prueba o con un pago pendiente (past_due). Así el acceso se mantiene mientras el proveedor reintenta un cobro fallido, y termina cuando la suscripción se cancela.
Tests
Para no llamar al proveedor en los tests, registra FakePaymentProvider. Anota los clientes, los pagos y las sesiones de portal que se le piden:
use Arrel\Billing\Contracts\PaymentProvider;
use Arrel\Billing\Testing\FakePaymentProvider;
$provider = new FakePaymentProvider;
$this->app->instance(PaymentProvider::class, $provider);
// ...
expect($provider->checkoutSessions)->toHaveCount(1);Los modelos traen factories para el catálogo, las suscripciones y los pedidos.