gusmanwidodo / laravel-billing
Generic, multi-purpose billing & invoicing for Laravel. Polymorphic billable, integer-minor-unit money (no float errors), line items with tax & discount, computed totals, and payment recording. Payment-gateway agnostic.
Requires
- php: ^8.3
- illuminate/contracts: ^12.0
- illuminate/database: ^12.0
- illuminate/support: ^12.0
- illuminate/view: ^12.0
Requires (Dev)
- dompdf/dompdf: ^3.0
- orchestra/testbench: ^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
Suggests
- dompdf/dompdf: Required to render invoices as PDF (composer require dompdf/dompdf).
README
Generic, multi-purpose billing & invoicing for Laravel. Attach invoices to any model (user, organization, tenant, project — anything), store money safely as integer minor units (no floating-point errors), build invoices with line items, tax, and discounts, and record payments. Payment-gateway agnostic by design.
Why
- Multi-purpose: invoices are polymorphic — bill a
User, anOrganization, aTenant, aProject, or nothing at all. - Money done right: all amounts are integer minor units (cents) plus a
currency code, so
0.1 + 0.2is exactly0.30— never a float rounding bug. - Computed totals: subtotal, tax, and total are derived from line items and persisted; recompute any time.
- Gateway-agnostic: the package records payments; it does not talk to Stripe,
Midtrans, etc. Bring your own gateway and call
recordPayment().
Requirements
- PHP
^8.3 - Laravel 12
Installation
composer require gusmanwidodo/laravel-billing
php artisan migrate
# optionally:
php artisan vendor:publish --tag=billing-config
php artisan vendor:publish --tag=billing-migrations
Quick start
use Gusmanwidodo\Billing\Facades\Billing; $invoice = Billing::newInvoice('USD') ->addItem('Pro plan', unitPrice: 5000, quantity: 2, taxRate: 0.10) // $50.00 x2, 10% tax ->addItem('Setup fee', unitPrice: 1500, discount: 500) // $15.00 - $5.00 ->discount(1000) // invoice-level discount: $10.00 ->dueInDays(14) ->create(); $invoice->total; // integer minor units, e.g. 11000 $invoice->total()->format(); // "110.00 USD"
All prices are integer minor units in the invoice currency (e.g. 5000 =
$50.00). Use the Money helper to convert:
use Gusmanwidodo\Billing\Support\Money; Money::fromMajor(50.00, 'USD')->amount; // 5000 Money::of(5000, 'USD')->format(); // "50.00 USD"
Billing any model
Add the HasInvoices trait to whatever you bill:
use Gusmanwidodo\Billing\Concerns\HasInvoices; class Organization extends Model { use HasInvoices; } $org->invoice('USD') ->addItem('Enterprise seat', 100000, quantity: 5) ->create(); $org->invoices; // all invoices billed to this org
The invoice's billable is a polymorphic relation, so the same package bills
users, orgs, tenants, or anything else — no per-type tables.
Totals
For each line item:
line subtotal = quantity * unit_price - discount
line tax = round(line subtotal * tax_rate)
For the invoice:
subtotal = Σ line subtotals
tax_total = Σ line taxes
total = subtotal - invoice_discount + tax_total
All integer arithmetic. Call Billing::recompute($invoice) after changing items.
Payments
// Record a payment (your gateway/cash/transfer — the package is agnostic): Billing::recordPayment($invoice, 4000, attributes: [ 'method' => 'transfer', 'reference' => 'TRX-123', ]); $invoice->refresh(); $invoice->status; // 'partially_paid' $invoice->amountDue()->format(); // remaining balance Billing::recordPayment($invoice, 7000); $invoice->refresh(); $invoice->status; // 'paid' (amount_paid >= total) $invoice->paid_at; // set automatically
amount_paid is always the sum of recorded payments; status is derived
(partially_paid / paid). Payments must match the invoice currency and be
positive.
Void
Billing::void($invoice); // status = 'void'; further payments are rejected
Status
Status is free-form — your app owns the workflow. Convenience constants are
provided, and the package sets partially_paid / paid automatically as
payments come in (and void when you void):
use Gusmanwidodo\Billing\Support\InvoiceStatus; InvoiceStatus::DRAFT; // 'draft' InvoiceStatus::SENT; // 'sent' InvoiceStatus::PARTIALLY_PAID; // 'partially_paid' InvoiceStatus::PAID; // 'paid' InvoiceStatus::VOID; // 'void'
Money & currencies
- Amounts are stored as
bigIntegerminor units; never floats. Money::fromMajor(10.50, 'USD')→1050. Passdecimals:for zero-decimal currencies (e.g. JPY):Money::fromMajor(1000, 'JPY', decimals: 0).Moneysupportsadd,subtract,multiply,percentage,format, and refuses cross-currency arithmetic.
Config
config/billing.php:
'default_currency' => 'USD', 'number_prefix' => 'INV-', // auto invoice numbers: INV-20260101-AB12CD 'decimals' => 2,
Testing
composer test # 55 tests: money, billable, payments, providers, subscriptions, pdf
Payment providers (v0.2)
Billing is gateway-agnostic: it defines a PaymentProvider contract and a
provider registry. A built-in manual provider handles offline (cash /
transfer) payments; real gateways (Stripe, Midtrans, ...) implement the same
interface and register themselves — typically from their own package.
The flow
use Gusmanwidodo\Billing\Facades\Billing; // 1. Open a charge -> returns a pending PaymentIntent (with redirect URL if the // provider uses hosted checkout). $intent = Billing::charge($invoice, provider: 'stripe'); $redirect = $intent->meta['redirect_url'] ?? null; // 2. The provider calls your webhook. Verify + settle in one call: $intent = Billing::handleWebhook('stripe', $request->getContent(), $request->headers->all()); // On a verified success event, a real Payment is recorded and the invoice // becomes 'paid' automatically. Signature verification is mandatory — a forged // or tampered webhook throws and records nothing. // 3. Refund a succeeded intent: Billing::refund($intent); // full Billing::refund($intent, 2000); // partial (minor units)
For manual payments there is no webhook — confirm in-app when you see the money:
$intent = Billing::charge($invoice, provider: 'manual'); // ...later, after confirming a bank transfer: Billing::confirmIntent($intent, ['reference' => 'TRANSFER-123']);
Writing a provider
Implement Gusmanwidodo\Billing\Payments\PaymentProvider and register it:
use Gusmanwidodo\Billing\Payments\{PaymentProvider, ChargeResult, WebhookEvent, RefundResult}; use Gusmanwidodo\Billing\Support\PaymentIntentStatus; class StripeProvider implements PaymentProvider { public function name(): string { return 'stripe'; } public function createCharge($invoice, int $amount, string $currency, array $options = []): ChargeResult { // ...call Stripe, create a PaymentIntent/Checkout Session... return new ChargeResult(externalId: $session->id, status: PaymentIntentStatus::PENDING, redirectUrl: $session->url); } public function verifyWebhook(string $payload, array $headers): bool { // MUST verify the signature. Return false on any mismatch. return \Stripe\Webhook::constructEvent($payload, $headers['Stripe-Signature'] ?? '', $secret) !== null; } public function parseWebhook(string $payload, array $headers): WebhookEvent { $event = json_decode($payload, true); return new WebhookEvent( type: $event['type'], externalId: $event['data']['object']['id'], status: PaymentIntentStatus::SUCCEEDED, // map from the event type amount: $event['data']['object']['amount'], currency: strtoupper($event['data']['object']['currency']), ); } public function refund($intent, ?int $amount = null): RefundResult { // ...call Stripe refund... return new RefundResult(externalId: $refund->id, amount: $amount ?? $intent->amount); } }
Register it from your service provider:
app(\Gusmanwidodo\Billing\Payments\PaymentProviderManager::class) ->register(new StripeProvider(), asDefault: true);
Set the default provider via config('billing.default_provider') or
BILLING_PROVIDER.
Security
Webhook signature verification is part of the contract — handleWebhook()
refuses to act on a webhook whose verifyWebhook() returns false, so a forged or
tampered payload never records a payment. Settlement is idempotent: replaying
the same success event records exactly one payment.
Subscriptions (v0.3)
Recurring billing via plans (price + interval) and subscriptions (a billable subscribed to a plan). The package computes each cycle; your app's scheduler triggers invoice generation.
use Gusmanwidodo\Billing\Facades\Subscriptions; use Gusmanwidodo\Billing\Models\Plan; // Define a plan $plan = Plan::create([ 'key' => 'pro-monthly', 'name' => 'Pro Monthly', 'amount' => 5000, 'currency' => 'USD', // $50.00 / cycle (minor units) 'interval' => 'month', 'interval_count' => 1, 'trial_days' => 14, // optional trial ]); // Subscribe any billable $sub = Subscriptions::subscribe($user, $plan); // or with the trait: $user->subscribeTo($plan); $user->subscribed(); // true $user->activeSubscription(); // the Subscription
Generating cycle invoices
The package does not run its own scheduler — you call generateDueInvoices()
from yours (e.g. in routes/console.php or a scheduled command):
// app/Console — run hourly/daily use Gusmanwidodo\Billing\Facades\Subscriptions; $invoices = Subscriptions::generateDueInvoices(); // For each subscription whose next_billing_at has arrived: creates an invoice // (status 'sent') and advances the cycle. Trialing subs activate on first bill.
// Laravel scheduler (bootstrap/app.php or a Kernel) $schedule->call(fn () => \Gusmanwidodo\Billing\Facades\Subscriptions::generateDueInvoices()) ->hourly();
Pair it with a payment provider to charge each generated invoice automatically
(e.g. in an after step, call Billing::charge($invoice, provider: 'stripe')).
Lifecycle
Subscriptions::pause($sub); // stop billing while paused Subscriptions::resume($sub); // reschedule next bill = now Subscriptions::cancel($sub, immediately: false); // cancel at period end (grace) Subscriptions::cancel($sub, immediately: true); // end access now
Statuses: trialing, active, paused, canceled, expired. A subscription
canceled at period end stays active and onGracePeriod() until ends_at.
Intervals
interval is day / week / month / year, times interval_count. Month and
year advancement is overflow-safe (Jan 31 + 1 month → Feb 28). Proration is out
of scope in v0.3.
Invoice PDF (v0.4)
Render invoices to HTML or PDF. HTML rendering is always available; PDF uses dompdf, which is an optional dependency — install it only if you need PDF:
composer require dompdf/dompdf
use Gusmanwidodo\Billing\Facades\Billing; $invoice = Billing::newInvoice('USD')->addItem('Plan', 5000)->create(); $html = $invoice->toHtml(); // always available $bytes = $invoice->toPdf(); // needs dompdf; raw PDF bytes return $invoice->downloadPdf('invoice.pdf'); // download response // Or resolve the services directly: app(\Gusmanwidodo\Billing\Pdf\InvoicePdf::class)->save($invoice, storage_path('inv.pdf'));
If dompdf is not installed, toPdf() throws a clear error telling you to install
it. Check availability with InvoicePdf::available().
Customizing the template
Publish the Blade template and edit it:
php artisan vendor:publish --tag=billing-views
# edits resources/views/vendor/billing/invoice.blade.php
Set a vendor name/address and paper size in config/billing.php:
'pdf' => [ 'view' => 'billing::invoice', 'paper' => 'a4', 'orientation' => 'portrait', 'vendor' => ['name' => 'Acme Inc.', 'address' => "123 Main St\nCity"], ],
Official provider packages
- gusmanwidodo/laravel-billing-midtrans — Midtrans (Snap) provider: charge, SHA512 webhook verification, refund.
- gusmanwidodo/laravel-billing-stripe — Stripe provider: Checkout charge, Stripe-Signature verification (+replay), refund.
Roadmap
- Usage-based / metered billing and proration.
License
MIT © Gusman Widodo. See LICENSE.