salehye/invoicing

A standalone Laravel invoicing library with multi-gateway payment support, bank transfer verification, and Overdue status tracking

Maintainers

Package info

github.com/salehye/invoicing

pkg:composer/salehye/invoicing

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-06-17 17:08 UTC

This package is auto-updated.

Last update: 2026-06-17 17:38:58 UTC


README

PHP 8.2+ Laravel 11|12|13 Tests License: MIT

A standalone Laravel invoicing package with multi-gateway payment support โ€” create invoices, manage line items with taxes/discounts, and process payments through Stripe, bank transfer (with manual admin verification), local testing, or any custom gateway.

Zero dependency on subscription billing packages โ€” use independently or alongside any subscription system.

โœจ Features

  • ๐Ÿงพ Invoice creation for any billable entity (polymorphic) or standalone (no billable required)
  • ๐Ÿ“ฆ Line items with quantity, pricing, per-line discount & tax
  • ๐Ÿ’ฐ Percentage & fixed discounts via DiscountType backed enum
  • ๐Ÿ“Š Configurable tax calculation (VAT, sales tax, etc.)
  • ๐Ÿ”ข Unique invoice number generation with collision-safe retry
  • ๐Ÿ”„ Status lifecycle: draft โ†’ unpaid โ†’ paid โ†’ refunded / canceled / overdue
  • โฐ Overdue detection + invoicing:mark-overdue artisan command
  • ๐Ÿ’ณ Multi-gateway payments (Stripe, Local, Bank Transfer with manual verification)
  • ๐Ÿ” Payment amount validation (no negative or excessive amounts)
  • โœ… Automatic invoice marking as paid when fully paid
  • ๐Ÿ“ก Laravel events with readonly immutable properties
  • ๐Ÿ›ก๏ธ Custom exception hierarchy for domain-specific errors
  • ๐Ÿšช Middleware to restrict routes by invoice payment status
  • ๐Ÿ”— HasInvoices trait for any Eloquent model
  • ๐Ÿ‘ค User ID tracking on invoices & payments
  • ๐Ÿข Tenant ID support (multi-tenant)
  • ๐Ÿ—‘๏ธ Soft deletes on invoices (audit trail)
  • ๐Ÿ”’ $fillable mass-assignment security
  • ๐Ÿ›ก๏ธ restrictOnDelete on FKs (no cascade deletes โ€” preserves audit trail)
  • ๐Ÿ“ Metadata JSON for extra data
  • ๐Ÿงฉ Customizable table names, currency, and user model
  • ๐ŸŽฏ Custom tax/discount calculator contracts

๐Ÿ“ฆ Installation

composer require salehye/invoicing

Publish Config & Migrations

php artisan vendor:publish --tag=invoicing-config
php artisan vendor:publish --tag=invoicing-migrations
php artisan migrate

Environment Variables

INVOICING_CURRENCY=SAR
INVOICING_GATEWAY=local

# Stripe (optional โ€” requires stripe/stripe-php)
STRIPE_API_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

# Bank Transfer (optional)
BANK_NAME=Al Rajhi Bank
BANK_ACCOUNT_NAME=My Company
BANK_ACCOUNT_NUMBER=1234567890
BANK_IBAN=SA0380000000608010167519
BANK_SWIFT_CODE=RJHISARI
BANK_TRANSFER_INSTRUCTIONS=Transfer the amount and upload proof of payment.

โš™๏ธ Configuration

Full config file at config/invoicing.php:

Key Default Description
currency USD Default currency per invoice
invoice_number_format {prefix}-{year}-{sequence} Invoice number template
invoice_number_prefix INV Invoice number prefix
invoice_number_sequence_length 4 Sequence digit count
default_gateway local Default payment gateway
gateways.* โ€” Per-gateway config (see Gateways section)
default_tax_rate 0 Default tax % (0 = no tax)
overdue_threshold_days 0 Days past due_at before overdue
table_names.invoices invoices Invoices table name
table_names.invoice_lines invoice_lines Invoice lines table name
table_names.payments payments Payments table name
user_model App\Models\User User model for relationships

๐Ÿš€ Quick Start

Step 1: Make a Model Billable

use Salehye\Invoicing\Traits\HasInvoices;

class Customer extends Model
{
    use HasInvoices;
}

This adds: invoices(), unpaidInvoices(), paidInvoices(), overdueInvoices(), totalInvoiceBalance()

Step 2: Create an Invoice

use Salehye\Invoicing\Facades\Invoicing;

$invoice = Invoicing::create([
    'billable'  => $customer,
    'title'     => 'Order #123',
    'currency'  => 'SAR',
    'due_at'    => now()->addDays(14),
    'user_id'   => auth()->id(),       // optional: track who created it
    'items'     => [
        ['description' => 'Product A', 'quantity' => 2, 'unit_price' => 150],
        ['description' => 'Product B', 'quantity' => 1, 'unit_price' => 300],
    ],
]);

// $invoice->subtotal = 600 (150*2 + 300)
// $invoice->total   = 600
// $invoice->status  = Draft
// $invoice->number  = "INV-2025-0001"

Step 3: Issue the Invoice

Invoicing::markAsIssued($invoice);
// draft โ†’ unpaid, issued_at is set

Step 4: Process Payment

use Salehye\Invoicing\Services\PaymentProcessor;

$processor = app(PaymentProcessor::class);

// Record a payment
$payment = $processor->recordPayment($invoice, 'manual', 600.00);
$processor->markAsSuccess($payment);

// Invoice automatically marked as paid!
$invoice->isPaid(); // true

๐Ÿ“‹ Invoices

Creating Invoices

// With billable entity
$invoice = Invoicing::create([
    'billable'  => $customer,
    'title'     => 'Service Invoice',
    'currency'  => 'SAR',
    'due_at'    => now()->addDays(30),
    'items'     => [
        ['description' => 'Web Development', 'quantity' => 1, 'unit_price' => 5000],
        ['description' => 'Hosting (12 months)', 'quantity' => 1, 'unit_price' => 1200],
    ],
]);

// Standalone (no billable)
$invoice = Invoicing::create([
    'title'     => 'Walk-in Sale',
    'currency'  => 'USD',
    'items'     => [
        ['description' => 'Coffee', 'quantity' => 3, 'unit_price' => 5],
    ],
]);

// With tenant ID (multi-tenant)
$invoice = Invoicing::create([
    'billable'  => $customer,
    'title'     => 'Tenant Invoice',
    'tenant_id' => 'tenant-123',
    'items'     => [...],
]);

Invoice with Discount & Tax

// Percentage discount (10% off)
$invoice = Invoicing::create([
    'billable'       => $customer,
    'title'          => 'Discounted Service',
    'items'          => [
        ['description' => 'Consultation', 'quantity' => 1, 'unit_price' => 1000],
    ],
    'discount'       => 10,
    'discount_type'  => 'percentage',  // DiscountType enum: 'percentage' or 'fixed'
    'tax'            => 15,             // 15% VAT
]);

// Calculation:
// Subtotal = 1000
// Discount (10%) = -100 โ†’ After discount = 900
// Tax (15% of 900) = +135
// Total = 1035

// Fixed discount (50 SAR off)
$invoice = Invoicing::create([
    'billable'       => $customer,
    'title'          => 'Fixed Discount',
    'items'          => [...],
    'discount'       => 50,
    'discount_type'  => 'fixed',
    'tax'            => 15,
]);

// Calculation:
// Subtotal = 1000, Discount = -50 โ†’ 950
// Tax (15% of 950) = +142.50
// Total = 1092.50

โš ๏ธ discount_type is required when discount > 0. No silent default โ€” the package throws an error if you provide a discount without specifying its type.

Invoice with Metadata

$invoice = Invoicing::create([
    'billable'  => $customer,
    'title'     => 'Order Invoice',
    'items'     => [...],
    'metadata'  => [
        'order_id'     => $order->id,
        'source'       => 'web_checkout',
        'coupon_code'  => 'SAVE20',
    ],
]);

// Access later
$invoice->metadata['order_id'];

Line Items with Per-Line Tax & Discount

$invoice = Invoicing::create([
    'billable'  => $customer,
    'title'     => 'Mixed Invoice',
    'items'     => [
        [
            'description' => 'Premium Service',
            'quantity'    => 1,
            'unit_price'  => 500,
            'discount'    => 50,       // per-line discount
            'tax'         => 75,       // per-line tax
        ],
    ],
]);
// Line total = (500*1) - 50 + 75 = 525

Adding Lines After Creation

Invoicing::addLine($invoice, [
    'description' => 'Late Fee',
    'quantity'    => 1,
    'unit_price'  => 50,
]);

// Recalculate totals after adding/removing lines
Invoicing::recalculateTotals($invoice);

Invoice Number Format

Customize in config:

'invoice_number_format' => '{prefix}-{year}-{sequence}',
'invoice_number_prefix' => 'INV',
'invoice_number_sequence_length' => 4,

Available placeholders: {prefix}, {year}, {month}, {sequence}

Examples: INV-2025-0001, INV-2025-06-0001

The generator automatically handles collisions with a bounded retry loop (max 10 attempts).

๐Ÿ”„ Invoice Lifecycle

Status Transitions

draft โ”€โ”€โ–บ unpaid โ”€โ”€โ–บ paid โ”€โ”€โ–บ refunded
 โ”‚         โ”‚    โ”‚
 โ””โ”€โ–บ canceled  โ””โ”€โ–บ overdue โ”€โ”€โ–บ paid / canceled

Operations

// Issue: draft โ†’ unpaid
Invoicing::markAsIssued($invoice);

// Pay: unpaid โ†’ paid
Invoicing::markAsPaid($invoice);

// Cancel: draft/unpaid โ†’ canceled
Invoicing::cancel($invoice);

// Refund: paid โ†’ refunded
Invoicing::refund($invoice);

Invalid transitions throw InvoiceStatusTransitionException.

Overdue Status

// Check if overdue (Unpaid + past due_at, or Overdue status)
$invoice->isOverdue(); // bool

// Mark as overdue (via command)
php artisan invoicing:mark-overdue

// Overdue invoices can transition to: Paid or Canceled
$invoice->status->canTransitionTo(InvoiceStatus::Paid);    // true
$invoice->status->canTransitionTo(InvoiceStatus::Canceled); // true

Configure grace period:

'overdue_threshold_days' => 0,  // 0 = immediately overdue after due_at
'overdue_threshold_days' => 3,  // 3 days grace period

Schedule the command in routes/console.php (Laravel 11+) or Console/Kernel.php:

$schedule->command('invoicing:mark-overdue')->dailyAt('08:00');

Status Helpers

$invoice->isDraft();      // status === Draft
$invoice->isUnpaid();     // status === Unpaid
$invoice->isPaid();       // status === Paid
$invoice->isCanceled();   // status === Canceled
$invoice->isRefunded();   // status === Refunded
$invoice->isOverdue();    // status === Overdue OR (Unpaid + past due_at)

Financial Helpers

$invoice->totalPaid();         // sum of successful payments
$invoice->remainingBalance();   // total - totalPaid (min 0)
$invoice->isFullyPaid();        // true if remainingBalance <= 0
$invoice->hasLines();           // true if invoice has line items
$invoice->lineCount();          // number of line items
$invoice->lines;                // HasMany relationship
$invoice->payments;             // HasMany relationship
$invoice->billable;             // MorphTo relationship (nullable)
$invoice->user;                 // BelongsTo User (nullable)

Query Scopes

Invoice::forTenant('tenant-1')->get();      // filter by tenant
Invoice::forUser(1)->get();                  // filter by user
Invoice::status(InvoiceStatus::Paid)->get(); // filter by status

๐Ÿ’ณ Payments

Record a Manual Payment

$processor = app(PaymentProcessor::class);

$payment = $processor->recordPayment(
    invoice:        $invoice,
    gateway:        'manual',
    amount:         500.00,
    transactionId:  'TXN-001',
    userId:         auth()->id(),  // optional
);

// Amount validation: must be > 0 and โ‰ค remainingBalance()
// Throws InvalidPaymentAmountException on violation

Mark Payment as Success/Failed

$processor->markAsSuccess($payment);
// Fires PaymentSucceeded event
// Auto-marks invoice as paid if fully paid

$processor->markAsFailed($payment);
// Fires PaymentFailed event

Create Checkout Session

$checkout = $processor->createCheckout(
    invoice:   $invoice,
    returnUrl: 'https://example.com/success',
    cancelUrl: 'https://example.com/cancel',
    gateway:   'stripe',  // optional, defaults to config
);

Handle Webhooks

$webhookEvent = $processor->handleWebhook($payload, 'stripe');

Refund

$processor->refund($invoice, null, 'stripe');
// Returns bool

Payment Helpers

$payment->isPending();
$payment->isAwaitingVerification();
$payment->isSuccess();
$payment->isFailed();
$payment->isRefunded();
$payment->needsVerification();
$payment->invoice;      // BelongsTo Invoice
$payment->user;         // BelongsTo User (nullable)
$payment->verifier;     // BelongsTo User via verified_by (nullable)

๐Ÿฆ Payment Gateways

Built-in Gateways

Gateway Status Description
LocalGateway โœ… Ready Auto-succeeds for local/testing
StripeGateway ๐Ÿ— Placeholder Requires stripe/stripe-php
BankTransferGateway โœ… Ready Manual admin verification

โš ๏ธ Stripe: throws RuntimeException if stripe/stripe-php is not installed. Install with composer require stripe/stripe-php.

Switch Default Gateway

// config/invoicing.php
'default_gateway' => 'stripe',

Or via env: INVOICING_GATEWAY=stripe

Use a Specific Gateway per Payment

$processor->createCheckout($invoice, '/success', '/cancel', 'stripe');
$processor->refund($invoice, null, 'stripe');

Add Custom Gateway โ€” Via Config

'gateways' => [
    'paypal' => [
        'driver' => \App\Gateways\PayPalGateway::class,
        'api_key' => env('PAYPAL_API_KEY'),
    ],
],

Add Custom Gateway โ€” Via Runtime

use Salehye\Invoicing\Services\GatewayManager;

app(GatewayManager::class)->register('paypal', \App\Gateways\PayPalGateway::class);

Custom Gateway Implementation

use Salehye\Invoicing\Contracts\PaymentGateway;
use Salehye\Invoicing\Contracts\WebhookEvent;
use Salehye\Invoicing\Models\Invoice;

class PayPalGateway implements PaymentGateway
{
    public function createCheckout(Invoice $invoice, string $returnUrl, string $cancelUrl): array
    {
        return ['checkout_url' => 'https://paypal.com/pay/...'];
    }

    public function handleWebhook(array $payload): ?WebhookEvent
    {
        return null;
    }

    public function getPaymentStatus(string $transactionId): string
    {
        return 'success';
    }

    public function refund(Invoice $invoice, ?float $amount = null): bool
    {
        return true;
    }
}

Gateway Manager API

$manager = app(GatewayManager::class);

$manager->names();      // ['local', 'stripe', 'bank_transfer', ...]
$manager->has('paypal'); // bool
$manager->gateway();     // default gateway instance
$manager->gateway('stripe'); // specific gateway instance

// Unregistered โ†’ throws GatewayNotFoundException
$manager->gateway('unknown');

๐Ÿฆ Bank Transfer (Manual Verification)

Flow

Customer initiates โ†’ Uploads proof โ†’ Payment: awaiting_verification
                                           โ†“
                        Admin reviews โ†’ verify() โ†’ Success โ†’ Invoice paid
                                           โ†“
                        Admin reviews โ†’ reject() โ†’ Failed

1. Customer Initiates

$processor = app(PaymentProcessor::class);

// Show bank details to customer
$checkout = $processor->createCheckout($invoice, '/success', '/cancel', 'bank_transfer');
// Returns: type, invoice_id, amount, currency, bank_details, reference, instructions

// Customer uploads proof
$payment = $processor->initiateBankTransfer($invoice, 'receipt.pdf', 'Paid via Al Rajhi');
// Status: awaiting_verification

2. Admin Verifies or Rejects

// Verify โ€” invoice auto-marked as paid if fully paid
$processor->verify($payment, auth()->id());
// verified_by = user ID (int), verified_at = now()

// Reject โ€” with reason
$processor->reject($payment, 'Receipt is unclear');
// proof_notes updated with reason

// Wrong status โ†’ throws PaymentVerificationException

3. Bank Details Config

'gateways' => [
    'bank_transfer' => [
        'bank_details' => [
            'bank_name'      => 'Al Rajhi Bank',
            'account_name'   => 'My Company',
            'account_number' => '1234567890',
            'iban'           => 'SA0380000000608010167519',
            'swift_code'     => 'RJHISARI',
        ],
        'instructions' => 'Transfer the amount and upload proof of payment.',
    ],
],

Payment Status Flow

pending โ†’ success / failed
awaiting_verification โ†’ success (verify) / failed (reject)
success โ†’ refunded

PaymentStatus Transitions

PaymentStatus::Pending->canTransitionTo(PaymentStatus::Success);    // true
PaymentStatus::Pending->canTransitionTo(PaymentStatus::Failed);     // true
PaymentStatus::Success->canTransitionTo(PaymentStatus::Refunded);   // true
PaymentStatus::Failed->canTransitionTo(PaymentStatus::Success);     // false

Invalid transitions throw PaymentStatusTransitionException.

๐Ÿ›ก๏ธ Exceptions

Exception Thrown When
InvoiceStatusTransitionException Invalid invoice status transition
PaymentStatusTransitionException Invalid payment status transition
PaymentVerificationException verify/reject on non-awaiting_verification payment
GatewayNotFoundException Unregistered gateway requested
InvalidPaymentAmountException Amount โ‰ค 0 or exceeds remaining balance

All extend standard PHP exceptions (RuntimeException / InvalidArgumentException) so they integrate naturally with Laravel's error handling.

๐Ÿ“ก Events

All events use public readonly properties (immutable after construction):

Event Property When
InvoiceCreated $invoice Invoice created
InvoiceUpdated $invoice Issued / totals recalculated
InvoicePaid $invoice Marked as paid
InvoiceCanceled $invoice Canceled
InvoiceRefunded $invoice Refunded
PaymentSucceeded $payment Payment succeeded
PaymentFailed $payment Payment failed
PaymentVerified $payment Admin verified bank transfer

Listening

// App\Providers\EventServiceProvider
protected $listen = [
    \Salehye\Invoicing\Events\InvoicePaid::class => [
        \App\Listeners\SendInvoicePaidNotification::class,
    ],
    \Salehye\Invoicing\Events\PaymentVerified::class => [
        \App\Listeners\NotifyCustomerPaymentVerified::class,
    ],
];

Example Listener

class SendInvoicePaidNotification
{
    public function handle(InvoicePaid $event): void
    {
        $invoice = $event->invoice;  // readonly โ€” cannot be modified
        Mail::to($invoice->billable)->send(new InvoicePaidMail($invoice));
    }
}

๐Ÿšช Middleware

EnsureInvoicePaid

The invoice.paid middleware alias is auto-registered by the package's ServiceProvider (works in Laravel 11, 12, and 13). No manual registration needed.

Restrict route access by invoice payment status:

// Just use it directly โ€” no manual registration required
Route::get('/downloads/{invoice}', [DownloadController::class, 'download'])
    ->middleware('invoice.paid:invoice');

// The parameter name is configurable: 'invoice.paid:invoice_id'

If you prefer manual registration, you can also add it in bootstrap/app.php:

// bootstrap/app.php (Laravel 11+)
$app->routeMiddleware([
    'invoice.paid' => \Salehye\Invoicing\Middleware\EnsureInvoicePaid::class,
]);

Note: The auto-registration is preferred and works out of the box. Manual registration is only needed if you want to override the alias or have a conflicting alias name.

๐Ÿ”— HasInvoices Trait

class Customer extends Model
{
    use HasInvoices;
}

// Available methods
$customer->invoices();            // MorphMany โ€” all invoices
$customer->draftInvoices();       // MorphMany โ€” status = Draft
$customer->unpaidInvoices();      // MorphMany โ€” status = Unpaid
$customer->paidInvoices();        // MorphMany โ€” status = Paid
$customer->canceledInvoices();    // MorphMany โ€” status = Canceled
$customer->overdueInvoices();     // MorphMany โ€” Overdue OR unpaid + past due_at
$customer->refundedinvoices();    // MorphMany โ€” status = Refunded
$customer->totalInvoiceBalance(); // float โ€” sum of unpaid totals
$customer->totalPaidAmount();     // float โ€” sum of paid totals

All methods return MorphMany with proper return type declarations.

๐Ÿ‘ค User ID Tracking

Track who created/owns invoices and payments (independent from billable):

// Invoice with user
$invoice = Invoicing::create([
    'billable' => $customer,
    'title'    => 'Order Invoice',
    'user_id'  => auth()->id(),
    'items'    => [...],
]);

$invoice->user; // BelongsTo โ†’ configured User model

// Payment with user
$payment = $processor->recordPayment($invoice, 'stripe', 100, userId: auth()->id());
$payment->user; // BelongsTo โ†’ configured User model

// Bank transfer inherits user_id from invoice
$payment = $processor->initiateBankTransfer($invoice);
// payment.user_id = invoice.user_id

// Override with explicit user
$payment = $processor->initiateBankTransfer($invoice, null, null, $otherUserId);

Custom User Model

// config/invoicing.php
'user_model' => \App\Models\Admin::class,

๐Ÿงฎ Custom Calculators

Tax Calculator

use Salehye\Invoicing\Contracts\TaxCalculator;

class SaudiVatCalculator implements TaxCalculator
{
    public function calculate(float $subtotal, ?array $metadata = null): float
    {
        return round($subtotal * 0.15, 2);
    }
}

// Register in a service provider
app()->singleton(TaxCalculator::class, SaudiVatCalculator::class);

Discount Calculator

use Salehye\Invoicing\Contracts\DiscountCalculator;

class CouponDiscountCalculator implements DiscountCalculator
{
    public function calculate(float $subtotal, ?array $metadata = null): float
    {
        $coupon = $metadata['coupon'] ?? null;
        return $coupon ? $coupon->applyTo($subtotal) : 0;
    }
}

app()->singleton(DiscountCalculator::class, CouponDiscountCalculator::class);

๐Ÿ—ƒ๏ธ Database Schema

invoices

Column Type Notes
id bigint PK
billable_type string Polymorphic (nullable)
billable_id bigint Polymorphic (nullable)
user_id bigint Nullable, indexed (app adds FK)
tenant_id string Nullable, indexed
number string Unique
title string Required
description text Nullable
currency string(3) Default: USD
subtotal decimal(12,2) Sum of line totals
discount decimal(12,2) Discount amount
discount_type enum percentage / fixed (DiscountType cast)
tax decimal(12,2) Tax amount
total decimal(12,2) Final total
status enum draft/unpaid/paid/canceled/refunded/overdue
issued_at timestamp Nullable
due_at timestamp Nullable
paid_at timestamp Nullable
metadata json Nullable
created_at timestamp
updated_at timestamp
deleted_at timestamp Soft delete

invoice_lines

Column Type Notes
id bigint PK
invoice_id bigint FKโ†’invoices (restrictOnDelete)
description string Required
quantity integer Default: 1
unit_price decimal(12,2)
discount decimal(12,2) Per-line discount
tax decimal(12,2) Per-line tax
total decimal(12,2) (unit_price ร— qty) โˆ’ discount + tax
metadata json Nullable

payments

Column Type Notes
id bigint PK
invoice_id bigint FKโ†’invoices (restrictOnDelete)
user_id bigint Nullable, indexed (app adds FK)
gateway string e.g. manual, stripe, bank_transfer
transaction_id string Nullable
amount decimal(12,2) Must be > 0 and โ‰ค remaining balance
currency string(3)
status enum pending/awaiting_verification/success/failed/refunded
gateway_response json Nullable
proof_file string Nullable โ€” bank transfer receipt
proof_notes string Nullable โ€” customer/admin notes
verified_at timestamp Nullable โ€” admin verification time
verified_by bigint Nullable, indexed โ€” admin user ID (app adds FK)
created_at timestamp
updated_at timestamp

FK Constraints: user_id and verified_by are foreignId (unsignedBigInteger) columns with indexes but without constrained() because the target user table is configurable. The consuming application should add FK constraints in their own migrations. invoice_id uses restrictOnDelete to preserve the financial audit trail.

๐Ÿงช Testing

composer install
vendor/bin/phpunit

56 tests, 121 assertions covering:

  • โœ… Invoice creation with items & totals
  • โœ… Percentage & fixed discount + tax calculations
  • โœ… DiscountType enum casting
  • โœ… Unique invoice number generation
  • โœ… Status lifecycle (draft โ†’ unpaid โ†’ paid โ†’ refunded โ†’ canceled โ†’ overdue)
  • โœ… Invalid transitions โ†’ InvoiceStatusTransitionException
  • โœ… Polymorphic billable relationships
  • โœ… Standalone invoices (no billable)
  • โœ… Line items
  • โœ… Overdue detection
  • โœ… Gateway registration & resolution
  • โœ… Custom gateway runtime registration
  • โœ… GatewayNotFoundException
  • โœ… Bank transfer: initiate, verify, reject
  • โœ… PaymentVerificationException
  • โœ… InvalidPaymentAmountException
  • โœ… User ID on invoices and payments
  • โœ… HasInvoices trait
  • โœ… discount_type validation (required when discount > 0)
  • โœ… PaymentStatus::canTransitionTo() transitions
  • โœ… PaymentStatusTransitionException on invalid transitions
  • โœ… Invoice::isFullyPaid(), hasLines(), lineCount()
  • โœ… Invoice scopes: forTenant(), forUser(), status()
  • โœ… HasInvoices: draftInvoices(), canceledInvoices(), refundedinvoices(), totalPaidAmount()
  • โœ… Overdue scope includes unpaid past due_at (not just Overdue status)

๐Ÿ“š Documentation

๐Ÿ“ Changelog

See CHANGELOG.md for all changes.

๐Ÿ“„ License

MIT โ€” free to use in personal and commercial projects.

๐Ÿค Contributing

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/my-feature)
  3. Commit your changes (git commit -am 'Add my feature')
  4. Push to the branch (git push origin feature/my-feature)
  5. Create a Pull Request

Please ensure all tests pass before submitting:

vendor/bin/phpunit