Skip to content

Facturació ​

arrel/billing és un paquet a part que afegeix subscripcions de pagament a un panell d'Arrel. Porta el model de dades (productes, plans, preus, subscripcions, comandes i descomptes), un contracte de proveïdor de pagament amb un driver per a Stripe, l'endpoint dels webhooks i una pàgina de facturació que s'afegeix al panell com a plugin. Necessita Arrel 1.2 o posterior.

php
Panel::make('admin')
    ->plugins([BillingPlugin::make()])

La pàgina mostra el pla actual i el seu estat, els plans disponibles amb un botó per subscriure-s'hi, un enllaç al portal de client del proveïdor i l'historial de pagaments.

Instal·lació ​

El paquet és en un repositori privat, com Arrel. Afegeix-lo a composer.json al costat del d'Arrel; el mateix token de GitHub (Instal·lació) hi ha de tenir accés de lectura:

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

I instal·la'l:

bash
composer require arrel/billing
php artisan migrate

Les migracions s'executen des del paquet. Publica-les, o la configuració o els textos, només si les has de canviar:

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

L'script i el full d'estil de la pàgina els serveix el mateix paquet, amb un hash del contingut a la URL. No cal publicar cap asset en desplegar.

Configuració ​

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

La resta és a config/billing.php:

ClauPer defecteDescripció
connectionnullConnexió de base de dades de totes les taules de facturació. En una aplicació multi-tenant, la connexió central.
abilitybilling.manageL'habilitat que es comprova abans de mostrar la pàgina o d'iniciar un pagament.
providerstripeEl proveïdor de pagament actiu.
webhook.pathbilling/webhooks/{provider}On el proveïdor envia els esdeveniments.
webhook.middleware['throttle:300,1']Middleware de la ruta del webhook. Va fora del grup web: sense sessió ni comprovació CSRF.

Totes les taules porten el prefix billing_.

Qui paga ​

El model que factures implementa Billable amb 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 afegeix subscriptions(), orders() i currentSubscription(), que és la subscripció creada més recentment. Les subscripcions i les comandes són polimòrfiques i guarden la clau com a text, així que funcionen amb claus enteres, UUID o de text.

Després digues al paquet qui paga a cada petició. Si no ho fas, paga l'usuari autenticat, si el seu model és Billable:

php
use Arrel\Billing\Billing;

Billing::resolveBillableUsing(fn (Request $request): ?Team => $request->user()?->currentTeam);

En una aplicació multi-tenant, el normal és que pagui el tenant. Una petició sense ningú que pagui rep un 403.

La pàgina ​

Registra el plugin als panells que han de mostrar la facturació:

php
Panel::make('admin')
    ->plugins([
        BillingPlugin::make()->sort(90)->group('Configuració'),
    ])

L'entrada de navegació només apareix als usuaris que tenen l'habilitat configurada, i les rutes de la pàgina (/arrel-plugins/billing) comproven la mateixa habilitat. Concedeix-la com concedeixes qualsevol altra cosa, amb un Gate, una Policy o un permís:

php
Gate::define('billing.manage', fn (User $user): bool => $user->is_owner);

El pagament i el portal de client tornen a la pàgina de facturació del panell des d'on s'han obert. L'identificador del panell es comprova contra els panells registrats, de manera que el proveïdor mai redirigeix a una adreça que hagi triat el navegador.

El catàleg ​

Productes, plans i preus són models d'Eloquent normals. Crea'ls com qualsevol altra dada, per exemple en un seeder:

php
$product = Product::query()->updateOrCreate(['key' => 'app'], ['name' => 'App']);

$plan = Plan::query()->updateOrCreate(
    ['key' => 'basic'],
    ['product_id' => $product->id, 'name' => 'Bàsic', 'sort_order' => 1],
);

Price::query()->updateOrCreate(
    ['plan_id' => $plan->id, 'interval' => PriceInterval::Month],
    ['currency' => 'eur', 'amount' => 2900],
);

Els imports són enters, en cèntims. Un preu només es pot vendre a través d'un proveïdor quan està vinculat amb l'identificador del preu en aquell proveïdor:

bash
php artisan billing:map-price basic month stripe price_1Q...

Iniciar un pagament amb un preu sense vincle per al proveïdor actiu llança MissingPriceMappingException.

Webhooks ​

Configura el proveïdor perquè enviï els esdeveniments a la ruta del webhook, /billing/webhooks/stripe per defecte. L'endpoint de Stripe ha de fer servir la versió de l'API 2025-03-31.basil o posterior.

El driver de Stripe actua sobre aquests esdeveniments i respon a la resta sense desar-los:

  • customer.subscription.created, customer.subscription.updated i customer.subscription.deleted actualitzen la subscripció.
  • invoice.paid i invoice.payment_failed registren una comanda.

Cada esdeveniment es verifica amb la seva signatura i es desa una sola vegada per proveïdor: un esdeveniment repetit no fa res. Si aplicar-lo falla, tot es desfà i el reintent del proveïdor es processa.

El pagament guarda l'identificador de la subscripció local a les metadades de la subscripció del proveïdor, de manera que cada esdeveniment troba la seva subscripció arribi en l'ordre que arribi. Un estat que el paquet no coneix es desa com a canceled: un estat inesperat mai dona accés.

Reaccionar als canvis ​

SubscriptionUpdated es dispara dins la mateixa transacció que aplica el webhook. Si un listener llança una excepció, l'esdeveniment es desfà i el proveïdor el torna 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() és cert mentre la subscripció està activa, en període de prova o amb un pagament pendent (past_due). Així l'accés aguanta mentre el proveïdor reintenta un cobrament fallit, i s'acaba quan la subscripció es cancel·la.

Tests ​

Per no cridar el proveïdor als tests, registra FakePaymentProvider. Apunta els clients, els pagaments i les sessions de portal que se li demanen:

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);

Els models porten factories per al catàleg, les subscripcions i les comandes.