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.
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:
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/arrelphp/arrel" },
{ "type": "vcs", "url": "https://github.com/arrelphp/billing" }
]
}I instal·la'l:
composer require arrel/billing
php artisan migrateLes migracions s'executen des del paquet. Publica-les, o la configuració o els textos, només si les has de canviar:
php artisan vendor:publish --tag=billing-config
php artisan vendor:publish --tag=billing-migrations
php artisan vendor:publish --tag=billing-translationsL'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ó
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...La resta és a config/billing.php:
| Clau | Per defecte | Descripció |
|---|---|---|
connection | null | Connexió de base de dades de totes les taules de facturació. En una aplicació multi-tenant, la connexió central. |
ability | billing.manage | L'habilitat que es comprova abans de mostrar la pàgina o d'iniciar un pagament. |
provider | stripe | El proveïdor de pagament actiu. |
webhook.path | billing/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:
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:
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ó:
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:
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:
$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:
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.updatedicustomer.subscription.deletedactualitzen la subscripció.invoice.paidiinvoice.payment_failedregistren 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:
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:
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.