Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

Napo7's avatar
Level 15

How to handle multiple states of an Invoice

Hi,

I'm working on an invoicing app, on which Invoices can have multiple "states" : An invoice can be :

  • Paid / Partly Paid, Not Paid
  • Late / Not late (regarding to due date)
  • Draft / Validated
  • Sent / Not Sent

Some of those states are exclusive, such as a draft invoice can't be paid, can't be late, can't be sent... But a validated invoice can be late or not, can be sent or not (I might want to be able to pay an invoice even if it has not been sent, ie if the customer is currently in my store and don't want to receive the invoice by email...). A validated invoice also can be partly paid and be late....

This seems to partly match a state machine pattern, but having some states that can be dependent of other states seems those other states are not really states but simple booleans....

The final solution should also allow to query invoices with those states (show all invoices that are not paid, that are late, etc...)

How would you tackle this ?

0 likes
5 replies
LaryAI's avatar
Level 58

To handle multiple states of an invoice in your invoicing app, you can use a combination of state management and boolean flags. This approach allows you to manage both exclusive and non-exclusive states effectively. Here's a step-by-step solution:

1. Define the States

First, define the possible states for an invoice. You can use an enum or constants to represent these states in your code. For example:

class InvoiceState
{
    const DRAFT = 'draft';
    const VALIDATED = 'validated';
    const PAID = 'paid';
    const PARTLY_PAID = 'partly_paid';
    const NOT_PAID = 'not_paid';
    const LATE = 'late';
    const NOT_LATE = 'not_late';
    const SENT = 'sent';
    const NOT_SENT = 'not_sent';
}

2. Use a State Machine for Exclusive States

For states that are mutually exclusive (like Draft/Validated), you can use a state machine pattern. This pattern helps manage transitions between states and ensures that only valid transitions occur.

3. Use Boolean Flags for Non-Exclusive States

For states that are not mutually exclusive (like Paid/Not Paid, Late/Not Late), use boolean flags. This allows you to combine these states with the exclusive states managed by the state machine.

4. Querying Invoices

To query invoices based on their states, you can use a database query with conditions that check both the state and the boolean flags. For example, using an ORM like Eloquent in Laravel:

$unpaidInvoices = Invoice::where('is_paid', false)->get();
$lateInvoices = Invoice::where('is_late', true)->get();
$validatedAndSentInvoices = Invoice::where('state', InvoiceState::VALIDATED)
                                   ->where('is_sent', true)
                                   ->get();

5. Consider Using a State Management Library

If your application grows more complex, consider using a state management library that supports state machines, such as spatie/laravel-state for Laravel. This can simplify the management of state transitions and ensure consistency.

By combining a state machine for exclusive states and boolean flags for non-exclusive states, you can effectively manage the various states of an invoice and query them as needed.

jamesbuch79's avatar

I wouldn't introduce a state management library or over-complicate it. Here's a state diagram for example:

Invoice state diagram (open in new tab)

Example (* but see note towards the end):

Use it like this:

// Creating a draft invoice
$invoice = Invoice::create([
    'number' => 'INV-2024-001',
    'status' => Invoice::STATUS_DRAFT,
    'total_amount' => 1000.00,
    'due_date' => Carbon::now()->addDays(30),
]);

// Validating an invoice
$invoice->validate();

// Recording a payment
$invoice->recordPayment(500.00);

// Marking as sent
$invoice->markAsSent();

// Querying invoices
$lateInvoices = Invoice::late()->get();
$unpaidInvoices = Invoice::unpaid()->get();
$unsentValidatedInvoices = Invoice::unsent()->get();
  • I probably wouldn't do it exactly like this, because of cumulative error problems with limited precision types, so I would store everything as cents in invoices, and calculate on that. So everything coming in should be multiplied by 100, and clean integer calculations can be used, then divided by 100 and formatted for display.

An example of this:

Schema::create('invoices', function (Blueprint $table) {
            $table->id();
            $table->string('number')->unique();
            $table->enum('status', ['draft', 'validated']);
            $table->enum('payment_status', ['not_paid', 'partially_paid', 'paid'])->nullable();
            $table->integer('total_amount');
            $table->integer('paid_amount')->default(0);
            $table->date('due_date')->nullable();
            $table->timestamp('sent_at')->nullable();
            $table->timestamps();
            
            // Add indexes for common queries
            $table->index(['status', 'payment_status']);
            $table->index(['status', 'due_date']);
            $table->index(['status', 'sent_at']);
});

Example use:

// Creating a draft invoice
$invoice = Invoice::create([
    'number' => 'INV-2024-001',
    'status' => Invoice::STATUS_DRAFT,
    // A thousand whole units
    // 33.50 would be 3350 for example
    'total_amount' => 100000,
    'due_date' => Carbon::now()->addDays(30),
]);

// Validating an invoice
$invoice->validate();

// Recording a payment of 500 dollars/pounds/euro
$invoice->recordPayment(50000);

// Marking as sent
$invoice->markAsSent();

// Querying invoices
$lateInvoices = Invoice::late()->get();
$unpaidInvoices = Invoice::unpaid()->get();
$unsentValidatedInvoices = Invoice::unsent()->get();

Use unsigned big integer in migrations instead of decimal, and maybe have something like:

// In the migration:
$table->unsignedBigInteger('total_amount_cents');  // Instead of decimal
$table->unsignedBigInteger('paid_amount_cents')->default(0);

// In the Invoice model:
class Invoice extends Model
{
    // ... the rest of it

    // Just add checks for division by zero if you expect that might happen
    public function getFormattedTotalAmount(): string
    {
        return sprintf('$%s', number_format($this->total_amount_cents / 100, 2));
    }

    public function getFormattedPaidAmount(): string
    {
        if ($this->paid_amount_cents === 0) {
              return sprintf('$%s', number_format($this->paid_amount_cents);
        }
        return sprintf('$%s', number_format($this->paid_amount_cents / 100, 2));
    }

    public function getFormattedRemainingAmount(): string
    {
        $remainingCents = $this->total_amount_cents - $this->paid_amount_cents;
        return sprintf('$%s', number_format($remainingCents / 100, 2));
    }
}

Usage example:

$invoice = Invoice::find(1);

// If total_amount_cents is 150000 (representing $1,500.00)
echo $invoice->getFormattedTotalAmount();  // Outputs: "$1,500.00"

// If paid_amount_cents is 50000 (representing $500.00)
echo $invoice->getFormattedPaidAmount();   // Outputs: "$500.00"
echo $invoice->getFormattedRemainingAmount(); // Outputs: "$1,000.00"

Accessor properties:

class Invoice extends Model
{
    protected $appends = ['formatted_total_amount', 'formatted_paid_amount'];
    
    public function getFormattedTotalAmountAttribute(): string
    {
        return sprintf('$%s', number_format($this->total_amount_cents / 100, 2));
    }

    public function getFormattedPaidAmountAttribute(): string
    {
        return sprintf('$%s', number_format($this->paid_amount_cents / 100, 2));
    }
}

$invoice->formatted_total_amount;  // "$1,500.00"

You could of course keep the formatted amounts a certain width, and exclude the dollar sign or replace with whichever currency you're using. numer_format can take the usual dot as dollar and cents separator, and nothing as the thousands separator, so amounts come out like "150.55" for example.

number_format(
    float $number,           // The number to format
    int $decimals = 0,       // Number of decimal points
    string $decimal_point = ".", // What to use as decimal point
    string $thousands_sep = "," // What to use as thousands separator
);
jamesbuch79's avatar

I would agree Aecy, it's not too complex, which is why I probably wouldn't introduce any further packages or libraries for this.

The invoice is draft on creation. The invoice is validated, or not. If validation fails, goes back to draft status. Once validated, payment status is: unpaid, fully paid, partially paid. Sent/delivery status is sent or not sent. DueStatus is Past Due (Late) or On Time.

There may be some other strange conditions to guard against. Like whether it makes sense to add a payment to a fully paid invoice.

Laravel has a nice events, observers, and listeners. Things can happen and states can change based on model updates, and they can combine to achieve the desired effect.

1 like
martinbean's avatar

@napo7 Yes, these are definitely states, and you’re correct that a state machine would be suitable here. However, I’d consider you have more than one state here.

For example, whether an invoice is paid, not paid, or paid would be a payment-specific state to me. Things like late and not late can be derived from other information (i.e. the due date) so I don’t feel they’re a state that you transition an invoice from and to, otherwise you’re going to need some sort of process that runs every second of the day and cannot fail otherwise you’ll end up with invalid data (i.e. “late” invoices that haven’t been marked as such if your process did not run for whatever reason).

So, I’d start trying to define the top-level states an invoice can be in. To borrow from Stripe’s terminology, this will probably be something like draft, open, paid, uncollectible, and void. An invoice would move to the open state when you’ve validated and sent it. An invoice would also remain in the open state until the balance has been paid in full. If the invoice has not ben paid or only partly paid, then it will remain in the open state.

Please or to participate in this conversation.