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.

Maintainers

Package info

github.com/gusmanwidodo/laravel-billing

pkg:composer/gusmanwidodo/laravel-billing

Transparency log

Statistics

Installs: 13

Dependents: 2

Suggesters: 0

Stars: 0

Open Issues: 0

v0.4.0 2026-08-26 13:01 UTC

This package is auto-updated.

Last update: 2026-08-26 13:27:42 UTC


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.

Tests License: MIT

Why

  • Multi-purpose: invoices are polymorphic — bill a User, an Organization, a Tenant, a Project, or nothing at all.
  • Money done right: all amounts are integer minor units (cents) plus a currency code, so 0.1 + 0.2 is exactly 0.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 bigInteger minor units; never floats.
  • Money::fromMajor(10.50, 'USD')1050. Pass decimals: for zero-decimal currencies (e.g. JPY): Money::fromMajor(1000, 'JPY', decimals: 0).
  • Money supports add, 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 contracthandleWebhook() 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

Roadmap

  • Usage-based / metered billing and proration.

License

MIT © Gusman Widodo. See LICENSE.