Skip to content

Billing · Open source

Billing Console

A free, read-only billing console for apps that bill with Laravel Cashier: customers, subscriptions, invoices and payments, with an MRR and churn summary, in Filament 4 and 5.

Filament 4 and 5 Laravel 12 and 13 PHP 8.2+ v1.0.0 Free · MIT
Billing Console: A free, read-only billing console for apps that bill with Laravel Cashier: customers, subscriptions, invoices and payments, with an MRR and churn summary, in Filament 4 and 5.

What it solves

Laravel Cashier (Stripe) keeps subscriptions in your own database and everything else in Stripe.

To see who pays, what failed and what a customer was invoiced, you switch between the app's database and the Stripe dashboard, and a missed webhook leaves the local subscription row out of date with no button to refresh it.

BillingConsolePlugin adds a Billing group to the panel. Customers and Subscriptions are read-only Filament resources over Cashier's own models and tables: the billable model (Cashier::$customerModel, rows with a stripe_id) and the subscriptions and subscription_items tables. Invoices and Payments are read live from the Stripe API (invoice and charge list calls), cached for 5 minutes, and searched, sorted and paginated in the panel. An MRR, active, on-trial and 30-day churn summary is worked out from the local subscription tables and the list prices of your recurring Stripe prices. Every record has an Open in Stripe link in test or live mode to match the key. Sync from Stripe, on a customer or a subscription, reads Stripe's copy and writes it only to your Cashier tables. The plugin makes only list and retrieve calls; it never creates, changes or cancels anything in Stripe. It has no migrations, config file or assets. An independent plugin, not made or endorsed by Filament, Stripe or Laravel.

  • Customers: the Cashier billable model's rows that have a Stripe customer ID, with payment method (brand and last four), a count of active, trialing and past-due subscriptions, search by name, email or Stripe ID, and a filter for who has one
  • A customer page with their details, their subscriptions from the local tables and their invoices and payments read from the Stripe API
  • Subscriptions from Cashier's tables: customer, type, status, price, trial end, end date and items, with a status filter and a "Cancelled, still in grace period" filter
  • A gray count of past-due and unpaid subscriptions next to the Subscriptions menu item
  • Invoices and Payments pages over the latest 100 invoices and charges (configurable down to 1), with links to the invoice PDF and the receipt and the failure reason under a failed payment
  • MRR at list price for active and past-due subscriptions, active and on-trial counts, and churn over the last 30 days, on the dashboard and above the subscriptions table
  • Open in Stripe on every customer, subscription, invoice and payment, pointing at the test or live dashboard to match the secret key
  • Sync from Stripe on a customer or subscription, behind a confirmation and a per-user limit of 10 a minute, writing only to the Cashier tables
  • Read-only resources: create, edit and delete are always denied, and there are no refund, cancel, swap or charge actions
  • Access through authorizeUsing(), a viewBillingConsole gate or your model policies; the secret key is never shown and key-like text is masked in error messages
The Subscriptions page in a light Filament panel, with Customers, Subscriptions (badge 1), Invoices and Payments under a Billing group. Four summary cards read MRR (USD) $180.00, list prices of active and past-due subscriptions; Active subscriptions 3, 1 past due in red; On trial 1, not counted in MRR; and Churn, last 30 days 20%, 1 cancelled and ended. Below, a table of six subscriptions for Globex Ltd, Stark Labs, Acme Inc, Initech, Hooli and Umbrella Co with type default, statuses Trialing, Active, Past due and Cancelled, a Stripe price ID, trial end, end and created dates, and a View action on each row.
The Subscriptions page in a light Filament panel, with Customers, Subscriptions (badge 1), Invoices and Payments under a Billing group. Four summary cards read MRR (USD) $180.00, list prices of active and past-due subscriptions; Active subscriptions 3, 1 past due in red; On trial 1, not counted in MRR; and Churn, last 30 days 20%, 1 cancelled and ended. Below, a table of six subscriptions for Globex Ltd, Stark Labs, Acme Inc, Initech, Hooli and Umbrella Co with type default, statuses Trialing, Active, Past due and Cancelled, a Stripe price ID, trial end, end and created dates, and a View action on each row.

Install

Requires PHP 8.2+, Filament 4 and 5, and Laravel 12 and 13.

composer require amjadiqbal/filament-billing-console

Usage

use AmjadIqbal\FilamentBillingConsole\BillingConsolePlugin;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->plugin(
            BillingConsolePlugin::make()
                ->authorizeUsing(fn (string $ability, $record, $user): bool => $user?->is_admin === true),
        );
}

Setup

Laravel Cashier (Stripe) must already be installed: laravel/cashier ^16 with its migrations run and a secret key in STRIPE_SECRET, which Cashier reads as cashier.secret. The plugin uses Cashier's own settings: Cashier::$customerModel (usually App\Models\User), Cashier::$subscriptionModel, cashier.secret and cashier.currency. It has no migrations, config file or assets of its own; its service provider only loads its English translations and registers its widgets with Livewire. Without a secret key the Stripe-backed tables show "No Stripe secret key is set" instead of data.

  1. 1 composer require amjadiqbal/filament-billing-console
  2. 2 Add ->plugin(BillingConsolePlugin::make()) to your panel provider.
  3. 3 Decide who may see billing data (see "Who can see it"); out of the box every user who can open the panel can.

What each page reads

Customers and Subscriptions are read from your database; Invoices and Payments are read from the Stripe API. The pages sit in a Billing group in this order: Customers, Subscriptions, Invoices, Payments.

  • Customers: rows of Cashier's billable model where stripe_id is set. The payment method column is the model's pm_type and pm_last_four. "Active subscriptions" and the "Has an active subscription" filter count subscriptions whose status is active, trialing or past_due. Search covers the name and email columns (set with customerAttributes()) and the Stripe ID.
  • A customer's page: name, email, Stripe ID, payment method, generic trial end and created date from the model, their subscriptions from the local tables, and their invoices and payments from Stripe filtered by their Stripe customer ID.
  • Subscriptions: Cashier's subscription model with its owner and subscription_items. The price column shows stripe_price, or "2 prices" for a multi-item subscription; the view page lists each item's price, product and quantity. Filters: status (active, trialing, past due, unpaid, incomplete, incomplete expired, paused, cancelled) and "Cancelled, still in grace period", meaning an ends_at in the future.
  • Invoices: Stripe's invoice list, newest first, up to maxListRecords() (100 by default and at most). Columns: number, customer email and name (linked to the customer page when the Stripe customer is in your table), status, total, amount due, created and due date, with a PDF link. Picking a status asks Stripe for invoices with that status, so older ones appear too.
  • Payments: Stripe's charge list, up to the same limit. Status is the charge's status, or Refunded or Partially refunded when money was returned; a failed charge shows Stripe's failure message. The method column is the card brand and last four, or the payment method type. The status filter works on the loaded charges only.
  • Search, sorting and pagination (10, 25 or 50 a page) on Invoices and Payments happen in the panel over the loaded records, not in Stripe.
  • Resources are not scoped to a panel tenant: every customer and subscription in the tables is listed.

How MRR and churn are worked out

The summary shows four cards: MRR, active subscriptions (with the past-due count under it), on trial, and churn over the last 30 days. Subscription counts come from the stripe_status column of your subscriptions table, so they are as current as your webhooks. These are quick operating numbers at list price, not accounting figures.

  • MRR adds up the subscription_items of subscriptions whose status is active or past_due. Trialing subscriptions are counted on their own card and not in MRR.
  • Each item's price is looked up among your recurring Stripe prices (read once and cached, up to 1,000 prices). Its unit_amount is turned into a monthly amount: divided by the interval count, then times 365/12 for daily, 52/12 for weekly, as is for monthly and divided by 12 for yearly, then multiplied by the item's quantity (a missing quantity counts as 1). The total is rounded to the smallest currency unit at the end.
  • Not counted: discounts and coupons, tax, metered prices, prices with no fixed unit amount (such as tiered prices), prices in a currency other than the report currency, and prices not found in the list. The card says how many items it left out.
  • The report currency is currency() on the plugin, or cashier.currency. Prices in other currencies are not converted.
  • Churn is the number of subscriptions with status canceled and an ends_at within the last 30 days, divided by that number plus the current active and past-due subscriptions, shown to one decimal place. It shows a dash when there is nothing to divide by.
  • If the prices cannot be read, the MRR card shows a dash with Stripe's error, and the counts still show. With no active or past-due items, MRR is 0 and Stripe is not called.
  • ->dashboardWidget(false) removes the summary from the dashboard; it still appears above the subscriptions table for users allowed overview.view.

Sync from Stripe

Sync from Stripe is a header action on a customer's and a subscription's page, behind a confirmation. It reads from Stripe and writes only to your own Cashier tables. Each user may run it 10 times a minute by default (syncsPerMinute()); every attempt counts, and over the limit a notification says how many seconds to wait.

  • On a subscription: it retrieves the subscription from Stripe and checks that its Stripe customer is this subscription's owner; if not, it refuses and changes nothing. It then runs Cashier's own customer.subscription.updated webhook handler, which stores stripe_status, stripe_price and quantity (only for a single-item subscription, otherwise null), trial_ends_at, ends_at, and the type if it was empty, then updates or creates the subscription_items rows (stripe_product, stripe_price, quantity) and deletes local item rows that are no longer on the Stripe subscription.
  • One difference from the webhook: when Stripe reports incomplete_expired, the webhook would delete the local subscription; the sync keeps the row and only updates its status.
  • For a subscription cancelled at period end, Cashier reads the period end from Stripe's subscription items during the sync, which is another read call.
  • On a customer: it refreshes the default payment method with Cashier's updateDefaultPaymentMethodFromStripe() (one customer retrieve), which stores pm_type and pm_last_four, or clears them when Stripe has none. Then it syncs, as above, each subscription of that customer that is already in your table. Subscriptions that exist only in Stripe are not imported. A payment method that cannot be read does not stop the subscriptions from syncing; the notification says it was not updated.
  • The action is shown only to users allowed customers.sync or subscriptions.sync, and the permission is checked again when it runs.

Who can see it

Every check goes through one place. The abilities are customers.viewAny, customers.view, customers.sync, subscriptions.viewAny, subscriptions.view, subscriptions.sync, invoices.viewAny, payments.viewAny and overview.view. In order:

  • authorizeUsing() on the plugin, if set, decides every ability on its own. The closure receives $ability, $record (a customer or subscription model, or null) and $user.
  • Otherwise, if your app defines a viewBillingConsole gate, a user it denies is refused everything.
  • Then the customer model's policy decides customers.* and the subscription model's policy decides subscriptions.*, through viewAny and view; sync uses a sync method if the policy has one, then update, then view. A policy without the method allows it. Invoices, payments and the overview have no policy step.
  • With none of these, every user who can open the panel sees the whole console and can run Sync from Stripe. Panel access is still guarded by canAccessPanel().
  • Create, edit and delete are always denied on both resources.
use Illuminate\Support\Facades\Gate;

Gate::define('viewBillingConsole', fn (User $user): bool => $user->is_admin);

Stripe API use

The console only makes list and retrieve calls. A search of the source at v1.0.0 finds no create, update, cancel, delete, refund or pay call on the Stripe client; the only Stripe calls are the invoice, charge and price lists, a subscription retrieve, and the customer and subscription item retrieves Cashier makes during a sync.

  • The invoice, charge and price lists are cached for 5 minutes by default (cacheFor(), 0 turns it off), per test or live mode and per filter. They go through a client that retries network errors twice. Failures are not cached, so the next page load tries again.
  • The reads made by Sync from Stripe are never cached, so a sync always sees Stripe's current copy.
  • A failed read shows a message in the table or on the MRR card instead of an error page: a rejected key, a missing permission, a rate limit, a network problem, or Stripe's own error text with anything that looks like a key masked. The error is also reported to your log.
  • A restricted key with read access is enough for the console. The README lists Customers, Subscriptions, Invoices, Charges, Prices and Payment Methods (read).

Options

All options are on the plugin and all are optional. navigationSort() is added to each page's own position (Customers 1, Subscriptions 2, Invoices 3, Payments 4).

BillingConsolePlugin::make()
    ->navigationGroup('Billing')          // default "Billing"
    ->navigationSort(10)
    ->customers(true)                     // turn any part off with false
    ->subscriptions(true)
    ->invoices(true)
    ->payments(true)
    ->dashboardWidget(true)               // the summary on the panel dashboard
    ->customerAttributes('name', 'email') // name and email columns; email may be null
    ->currency('usd')                     // MRR currency, default cashier.currency
    ->cacheFor(300)                       // seconds to cache Stripe list reads, 0 for none
    ->maxListRecords(100)                 // invoices or payments loaded per table, 1 to 100
    ->syncsPerMinute(10);                 // Sync from Stripe runs per user per minute

Credits and trademarks

Built on Laravel Cashier (MIT) and stripe-php (MIT). This is an independent project. It is not affiliated with, endorsed by or sponsored by Stripe, Inc. or Laravel Holdings Inc. "Stripe" and "Laravel" are trademarks of their owners and are used here only to say what the plugin works with. It is also not made or endorsed by Filament.

Hire me

Need this built into your app, or something custom?

I wrote Billing Console. I can wire it into your Filament panel, extend it for your case, or build the feature around it. Tell me what you need and you'll get a plan and a quote, free.

Support

Found a bug or want a feature? Open an issue on GitHub. For help adding it to your own project, use the quote form above.

Need something built?

Free quote in 60 seconds