Skip to content

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.

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

json
{
    "repositories": [
        { "type": "vcs", "url": "https://github.com/arrelphp/arrel" },
        { "type": "vcs", "url": "https://github.com/arrelphp/billing" }
    ]
}

E instálalo:

bash
composer require arrel/billing
php artisan migrate

Las migraciones se ejecutan desde el paquete. Publícalas, o la configuración o los textos, solo si las tienes que cambiar:

bash
php artisan vendor:publish --tag=billing-config
php artisan vendor:publish --tag=billing-migrations
php artisan vendor:publish --tag=billing-translations

El 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 ​

dotenv
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

El resto está en config/billing.php:

ClavePor defectoDescripción
connectionnullConexión de base de datos de todas las tablas de facturación. En una aplicación multi-tenant, la conexión central.
abilitybilling.manageLa habilidad que se comprueba antes de mostrar la página o de iniciar un pago.
providerstripeEl proveedor de pago activo.
webhook.pathbilling/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:

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

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

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

php
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.

Productos, planes y precios son modelos de Eloquent normales. Créalos como cualquier otro dato, por ejemplo en un seeder:

php
$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:

bash
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.updated y customer.subscription.deleted actualizan la suscripción.
  • invoice.paid e invoice.payment_failed registran 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:

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

php
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.