php-commerce/tests/Feature/InvoiceServiceTest.php
Claude b624757c3c
test: add comprehensive tests for InvoiceService
Covers invoice generation from orders and renewals, line item copying,
tax calculations, PDF generation/retrieval, email sending, status
transitions, workspace queries, model scopes, and relationships.

Fixes #5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:25:33 +00:00

1161 lines
42 KiB
PHP

<?php
declare(strict_types=1);
use Barryvdh\DomPDF\Facade\Pdf;
use Core\Mod\Commerce\Mail\InvoiceGenerated;
use Core\Mod\Commerce\Models\Invoice;
use Core\Mod\Commerce\Models\InvoiceItem;
use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Models\OrderItem;
use Core\Mod\Commerce\Models\Payment;
use Core\Mod\Commerce\Models\TaxRate;
use Core\Mod\Commerce\Services\InvoiceService;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function () {
Mail::fake();
Storage::fake('local');
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create([
'billing_email' => 'billing@example.com',
'billing_name' => 'Test Company',
'billing_country' => 'GB',
]);
$this->workspace->users()->attach($this->user->id, [
'role' => 'owner',
'is_default' => true,
]);
// Seed UK VAT rate for tax calculations
TaxRate::create([
'country_code' => 'GB',
'name' => 'UK VAT',
'type' => 'vat',
'rate' => 20.00,
'is_digital_services' => true,
'effective_from' => '2020-01-01',
'is_active' => true,
]);
$this->service = app(InvoiceService::class);
});
describe('InvoiceService', function () {
describe('createFromOrder()', function () {
it('creates an invoice from an order without payment', function () {
$order = Order::create([
'orderable_type' => Workspace::class,
'orderable_id' => $this->workspace->id,
'user_id' => $this->user->id,
'order_number' => 'ORD-20260324-ABC123',
'status' => 'pending',
'type' => 'subscription',
'currency' => 'GBP',
'subtotal' => 100.00,
'tax_amount' => 20.00,
'tax_rate' => 20.00,
'tax_country' => 'GB',
'discount_amount' => 0,
'total' => 120.00,
'billing_name' => 'Test Company',
'billing_email' => 'billing@example.com',
]);
OrderItem::create([
'order_id' => $order->id,
'item_type' => 'package',
'description' => 'Creator Plan (Monthly)',
'quantity' => 1,
'unit_price' => 100.00,
'line_total' => 120.00,
'billing_cycle' => 'monthly',
]);
$invoice = $this->service->createFromOrder($order);
expect($invoice)->toBeInstanceOf(Invoice::class)
->and($invoice->workspace_id)->toBe($this->workspace->id)
->and($invoice->order_id)->toBe($order->id)
->and($invoice->payment_id)->toBeNull()
->and($invoice->status)->toBe('pending')
->and((float) $invoice->subtotal)->toBe(100.00)
->and((float) $invoice->tax_amount)->toBe(20.00)
->and((float) $invoice->total)->toBe(120.00)
->and((float) $invoice->amount_paid)->toBe(0.00)
->and((float) $invoice->amount_due)->toBe(120.00)
->and($invoice->currency)->toBe('GBP')
->and($invoice->billing_name)->toBe('Test Company')
->and($invoice->billing_email)->toBe('billing@example.com')
->and($invoice->invoice_number)->toStartWith('INV-');
});
it('creates a paid invoice when payment is provided', function () {
$order = Order::create([
'orderable_type' => Workspace::class,
'orderable_id' => $this->workspace->id,
'user_id' => $this->user->id,
'order_number' => 'ORD-20260324-DEF456',
'status' => 'paid',
'type' => 'subscription',
'currency' => 'GBP',
'subtotal' => 49.00,
'tax_amount' => 9.80,
'tax_rate' => 20.00,
'tax_country' => 'GB',
'discount_amount' => 0,
'total' => 58.80,
'billing_name' => 'Test Company',
'billing_email' => 'billing@example.com',
]);
OrderItem::create([
'order_id' => $order->id,
'item_type' => 'package',
'description' => 'Agency Plan (Monthly)',
'quantity' => 1,
'unit_price' => 49.00,
'line_total' => 58.80,
'billing_cycle' => 'monthly',
]);
$payment = Payment::create([
'workspace_id' => $this->workspace->id,
'gateway' => 'stripe',
'gateway_payment_id' => 'pi_test_paid',
'amount' => 58.80,
'fee' => 1.50,
'net_amount' => 57.30,
'refunded_amount' => 0,
'currency' => 'GBP',
'status' => 'succeeded',
'paid_at' => now(),
]);
$invoice = $this->service->createFromOrder($order, $payment);
expect($invoice->status)->toBe('paid')
->and($invoice->payment_id)->toBe($payment->id)
->and((float) $invoice->amount_paid)->toBe(58.80)
->and((float) $invoice->amount_due)->toBe(0.00)
->and($invoice->paid_at)->not->toBeNull();
});
it('copies line items from the order to the invoice', function () {
$order = Order::create([
'orderable_type' => Workspace::class,
'orderable_id' => $this->workspace->id,
'user_id' => $this->user->id,
'order_number' => 'ORD-20260324-GHI789',
'status' => 'pending',
'type' => 'subscription',
'currency' => 'GBP',
'subtotal' => 68.00,
'tax_amount' => 13.60,
'tax_rate' => 20.00,
'tax_country' => 'GB',
'discount_amount' => 0,
'total' => 81.60,
'billing_name' => 'Test Company',
'billing_email' => 'billing@example.com',
]);
$item1 = OrderItem::create([
'order_id' => $order->id,
'item_type' => 'package',
'description' => 'Agency Plan (Monthly)',
'quantity' => 1,
'unit_price' => 49.00,
'line_total' => 58.80,
'billing_cycle' => 'monthly',
]);
$item2 = OrderItem::create([
'order_id' => $order->id,
'item_type' => 'addon',
'description' => 'Extra Storage',
'quantity' => 2,
'unit_price' => 9.50,
'line_total' => 22.80,
'billing_cycle' => 'monthly',
]);
$invoice = $this->service->createFromOrder($order);
$invoiceItems = $invoice->items;
expect($invoiceItems)->toHaveCount(2);
$firstItem = $invoiceItems->where('order_item_id', $item1->id)->first();
expect($firstItem)->not->toBeNull()
->and($firstItem->description)->toBe('Agency Plan (Monthly)')
->and($firstItem->quantity)->toBe(1)
->and((float) $firstItem->unit_price)->toBe(49.00)
->and((float) $firstItem->line_total)->toBe(58.80);
$secondItem = $invoiceItems->where('order_item_id', $item2->id)->first();
expect($secondItem)->not->toBeNull()
->and($secondItem->description)->toBe('Extra Storage')
->and($secondItem->quantity)->toBe(2)
->and((float) $secondItem->unit_price)->toBe(9.50)
->and((float) $secondItem->line_total)->toBe(22.80);
});
it('generates sequential invoice numbers within a year', function () {
$order = Order::create([
'orderable_type' => Workspace::class,
'orderable_id' => $this->workspace->id,
'user_id' => $this->user->id,
'order_number' => 'ORD-20260324-SEQ001',
'status' => 'pending',
'type' => 'subscription',
'currency' => 'GBP',
'subtotal' => 19.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 19.00,
'billing_name' => 'Test Company',
'billing_email' => 'billing@example.com',
]);
// Ensure at least one order item exists for the relationship
OrderItem::create([
'order_id' => $order->id,
'item_type' => 'package',
'description' => 'Plan',
'quantity' => 1,
'unit_price' => 19.00,
'line_total' => 19.00,
'billing_cycle' => 'monthly',
]);
$invoice1 = $this->service->createFromOrder($order);
$order2 = Order::create([
'orderable_type' => Workspace::class,
'orderable_id' => $this->workspace->id,
'user_id' => $this->user->id,
'order_number' => 'ORD-20260324-SEQ002',
'status' => 'pending',
'type' => 'subscription',
'currency' => 'GBP',
'subtotal' => 29.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 29.00,
'billing_name' => 'Test Company',
'billing_email' => 'billing@example.com',
]);
OrderItem::create([
'order_id' => $order2->id,
'item_type' => 'package',
'description' => 'Plan 2',
'quantity' => 1,
'unit_price' => 29.00,
'line_total' => 29.00,
'billing_cycle' => 'monthly',
]);
$invoice2 = $this->service->createFromOrder($order2);
// Both should have valid prefixed invoice numbers
$year = now()->format('Y');
expect($invoice1->invoice_number)->toMatch("/^INV-{$year}-\\d{4}$/")
->and($invoice2->invoice_number)->toMatch("/^INV-{$year}-\\d{4}$/");
// Second invoice number should be greater than first
$num1 = (int) substr($invoice1->invoice_number, -4);
$num2 = (int) substr($invoice2->invoice_number, -4);
expect($num2)->toBe($num1 + 1);
});
it('sets correct due date from config', function () {
config(['commerce.billing.invoice_due_days' => 30]);
$order = Order::create([
'orderable_type' => Workspace::class,
'orderable_id' => $this->workspace->id,
'user_id' => $this->user->id,
'order_number' => 'ORD-20260324-DUE001',
'status' => 'pending',
'type' => 'subscription',
'currency' => 'GBP',
'subtotal' => 19.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 19.00,
'billing_name' => 'Test Company',
'billing_email' => 'billing@example.com',
]);
OrderItem::create([
'order_id' => $order->id,
'item_type' => 'package',
'description' => 'Plan',
'quantity' => 1,
'unit_price' => 19.00,
'line_total' => 19.00,
'billing_cycle' => 'monthly',
]);
$invoice = $this->service->createFromOrder($order);
expect($invoice->issue_date->toDateString())->toBe(now()->toDateString())
->and($invoice->due_date->toDateString())->toBe(now()->addDays(30)->toDateString());
});
});
describe('createForRenewal()', function () {
it('creates a pending invoice for subscription renewal', function () {
$invoice = $this->service->createForRenewal(
$this->workspace,
19.00,
'Creator Plan - Monthly Renewal'
);
expect($invoice)->toBeInstanceOf(Invoice::class)
->and($invoice->workspace_id)->toBe($this->workspace->id)
->and($invoice->status)->toBe('pending')
->and((float) $invoice->subtotal)->toBe(19.00)
->and((float) $invoice->amount_due)->toBeGreaterThan(0.0)
->and((float) $invoice->amount_paid)->toBe(0.00)
->and($invoice->billing_name)->toBe('Test Company')
->and($invoice->billing_email)->toBe('billing@example.com');
});
it('creates a paid renewal invoice when payment is provided', function () {
$payment = Payment::create([
'workspace_id' => $this->workspace->id,
'gateway' => 'btcpay',
'gateway_payment_id' => 'btc_renewal_001',
'amount' => 22.80,
'fee' => 0,
'net_amount' => 22.80,
'refunded_amount' => 0,
'currency' => 'GBP',
'status' => 'succeeded',
'paid_at' => now(),
]);
$invoice = $this->service->createForRenewal(
$this->workspace,
19.00,
'Creator Plan - Monthly Renewal',
$payment
);
expect($invoice->status)->toBe('paid')
->and($invoice->payment_id)->toBe($payment->id)
->and((float) $invoice->amount_due)->toBe(0.00)
->and($invoice->paid_at)->not->toBeNull();
});
it('calculates tax correctly for UK workspace', function () {
$invoice = $this->service->createForRenewal(
$this->workspace,
100.00,
'Agency Plan - Monthly Renewal'
);
// UK VAT at 20%
expect((float) $invoice->subtotal)->toBe(100.00)
->and((float) $invoice->tax_amount)->toBe(20.00)
->and((float) $invoice->tax_rate)->toBe(20.00)
->and((float) $invoice->total)->toBe(120.00)
->and($invoice->tax_country)->toBe('GB');
});
it('creates a single line item for the renewal', function () {
$invoice = $this->service->createForRenewal(
$this->workspace,
49.00,
'Agency Plan - Monthly Renewal'
);
$items = $invoice->items;
expect($items)->toHaveCount(1);
$item = $items->first();
expect($item->description)->toBe('Agency Plan - Monthly Renewal')
->and($item->quantity)->toBe(1)
->and((float) $item->unit_price)->toBe(49.00)
->and((float) $item->tax_rate)->toBe(20.00)
->and((float) $item->tax_amount)->toBe(9.80);
});
it('uses the configured default currency', function () {
config(['commerce.currency' => 'USD']);
$invoice = $this->service->createForRenewal(
$this->workspace,
19.00,
'Creator Plan - Monthly Renewal'
);
expect($invoice->currency)->toBe('USD');
});
});
describe('markAsPaid()', function () {
it('transitions invoice from pending to paid', function () {
$invoice = Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2026-1001',
'status' => 'pending',
'subtotal' => 19.00,
'tax_amount' => 3.80,
'discount_amount' => 0,
'total' => 22.80,
'amount_paid' => 0,
'amount_due' => 22.80,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->addDays(14),
]);
$payment = Payment::create([
'workspace_id' => $this->workspace->id,
'gateway' => 'stripe',
'gateway_payment_id' => 'pi_mark_paid',
'amount' => 22.80,
'fee' => 0.50,
'net_amount' => 22.30,
'refunded_amount' => 0,
'currency' => 'GBP',
'status' => 'succeeded',
'paid_at' => now(),
]);
$this->service->markAsPaid($invoice, $payment);
$invoice->refresh();
expect($invoice->status)->toBe('paid')
->and($invoice->payment_id)->toBe($payment->id)
->and((float) $invoice->amount_paid)->toBe(22.80)
->and((float) $invoice->amount_due)->toBe(0.00)
->and($invoice->paid_at)->not->toBeNull();
});
});
describe('void()', function () {
it('transitions invoice to void status', function () {
$invoice = Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2026-2001',
'status' => 'pending',
'subtotal' => 19.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 19.00,
'amount_paid' => 0,
'amount_due' => 19.00,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->addDays(14),
]);
$this->service->void($invoice);
$invoice->refresh();
expect($invoice->status)->toBe('void')
->and($invoice->isVoid())->toBeTrue();
});
});
describe('generatePdf()', function () {
it('generates a PDF and stores it on disk', function () {
$invoice = Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2026-3001',
'status' => 'paid',
'subtotal' => 49.00,
'tax_amount' => 9.80,
'discount_amount' => 0,
'total' => 58.80,
'amount_paid' => 58.80,
'amount_due' => 0,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->addDays(14),
'paid_at' => now(),
]);
InvoiceItem::create([
'invoice_id' => $invoice->id,
'description' => 'Agency Plan (Monthly)',
'quantity' => 1,
'unit_price' => 49.00,
'line_total' => 58.80,
'tax_rate' => 20.00,
'tax_amount' => 9.80,
]);
// Mock the PDF facade
$mockPdf = Mockery::mock();
$mockPdf->shouldReceive('output')->andReturn('%PDF-1.4 fake content');
Pdf::shouldReceive('loadView')
->once()
->with('commerce::pdf.invoice', Mockery::type('array'))
->andReturn($mockPdf);
$filename = $this->service->generatePdf($invoice);
expect($filename)->toContain($invoice->invoice_number)
->and($filename)->toContain((string) $this->workspace->id);
Storage::disk('local')->assertExists($filename);
$invoice->refresh();
expect($invoice->pdf_path)->toBe($filename);
});
it('stores PDF in configured storage path', function () {
config(['commerce.pdf.storage_path' => 'custom-invoices']);
$invoice = Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2026-3002',
'status' => 'paid',
'subtotal' => 19.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 19.00,
'amount_paid' => 19.00,
'amount_due' => 0,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->addDays(14),
'paid_at' => now(),
]);
$mockPdf = Mockery::mock();
$mockPdf->shouldReceive('output')->andReturn('%PDF-1.4 fake content');
Pdf::shouldReceive('loadView')->andReturn($mockPdf);
$filename = $this->service->generatePdf($invoice);
expect($filename)->toStartWith('custom-invoices/');
});
});
describe('getPdf()', function () {
it('returns existing PDF path when file exists on disk', function () {
$invoice = Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2026-4001',
'status' => 'paid',
'subtotal' => 19.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 19.00,
'amount_paid' => 19.00,
'amount_due' => 0,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->addDays(14),
'pdf_path' => "invoices/{$this->workspace->id}/INV-2026-4001.pdf",
]);
// Put a file on the faked disk
Storage::disk('local')->put($invoice->pdf_path, 'existing pdf content');
$path = $this->service->getPdf($invoice);
expect($path)->toBe($invoice->pdf_path);
});
it('generates a new PDF when file does not exist', function () {
$invoice = Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2026-4002',
'status' => 'paid',
'subtotal' => 19.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 19.00,
'amount_paid' => 19.00,
'amount_due' => 0,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->addDays(14),
'pdf_path' => null,
]);
$mockPdf = Mockery::mock();
$mockPdf->shouldReceive('output')->andReturn('%PDF-1.4 fake content');
Pdf::shouldReceive('loadView')->andReturn($mockPdf);
$path = $this->service->getPdf($invoice);
expect($path)->toContain($invoice->invoice_number);
Storage::disk('local')->assertExists($path);
});
});
describe('sendEmail()', function () {
it('queues an invoice email to the billing address', function () {
$invoice = Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2026-5001',
'status' => 'paid',
'subtotal' => 19.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 19.00,
'amount_paid' => 19.00,
'amount_due' => 0,
'currency' => 'GBP',
'billing_email' => 'invoices@example.com',
'issue_date' => now(),
'due_date' => now()->addDays(14),
'pdf_path' => "invoices/{$this->workspace->id}/INV-2026-5001.pdf",
]);
// Ensure the PDF exists so getPdf returns immediately
Storage::disk('local')->put($invoice->pdf_path, 'pdf content');
$this->service->sendEmail($invoice);
Mail::assertQueued(InvoiceGenerated::class, function ($mailable) {
return $mailable->hasTo('invoices@example.com');
});
});
it('does not send email when config is disabled', function () {
config(['commerce.billing.send_invoice_emails' => false]);
$invoice = Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2026-5002',
'status' => 'paid',
'subtotal' => 19.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 19.00,
'amount_paid' => 19.00,
'amount_due' => 0,
'currency' => 'GBP',
'billing_email' => 'invoices@example.com',
'issue_date' => now(),
'due_date' => now()->addDays(14),
]);
$this->service->sendEmail($invoice);
Mail::assertNothingQueued();
});
it('does not send email when no recipient can be resolved', function () {
$invoice = Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2026-5003',
'status' => 'paid',
'subtotal' => 19.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 19.00,
'amount_paid' => 19.00,
'amount_due' => 0,
'currency' => 'GBP',
'billing_email' => null,
'issue_date' => now(),
'due_date' => now()->addDays(14),
'pdf_path' => "invoices/{$this->workspace->id}/INV-2026-5003.pdf",
]);
// Clear workspace billing email so fallback also fails
$this->workspace->update(['billing_email' => null]);
Storage::disk('local')->put($invoice->pdf_path, 'pdf content');
$this->service->sendEmail($invoice);
Mail::assertNothingQueued();
});
});
describe('getForWorkspace()', function () {
it('returns paginated invoices for a workspace', function () {
// Create several invoices
foreach (range(1, 5) as $i) {
Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => "INV-2026-600{$i}",
'status' => $i <= 3 ? 'paid' : 'pending',
'subtotal' => 19.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 19.00,
'amount_paid' => $i <= 3 ? 19.00 : 0,
'amount_due' => $i <= 3 ? 0 : 19.00,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->addDays(14),
]);
}
$result = $this->service->getForWorkspace($this->workspace, 3);
expect($result)->toBeInstanceOf(LengthAwarePaginator::class)
->and($result->total())->toBe(5)
->and($result->perPage())->toBe(3)
->and($result->count())->toBe(3);
});
it('does not return invoices from other workspaces', function () {
$otherWorkspace = Workspace::factory()->create();
Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2026-6010',
'status' => 'paid',
'subtotal' => 19.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 19.00,
'amount_paid' => 19.00,
'amount_due' => 0,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->addDays(14),
]);
Invoice::create([
'workspace_id' => $otherWorkspace->id,
'invoice_number' => 'INV-2026-6011',
'status' => 'paid',
'subtotal' => 29.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 29.00,
'amount_paid' => 29.00,
'amount_due' => 0,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->addDays(14),
]);
$result = $this->service->getForWorkspace($this->workspace);
expect($result->total())->toBe(1);
});
});
describe('getUnpaidForWorkspace()', function () {
it('returns pending invoices that are not yet due', function () {
// Pending, not yet due
Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2026-7001',
'status' => 'pending',
'subtotal' => 19.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 19.00,
'amount_paid' => 0,
'amount_due' => 19.00,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->addDays(14),
]);
// Paid invoice (should not appear)
Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2026-7002',
'status' => 'paid',
'subtotal' => 29.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 29.00,
'amount_paid' => 29.00,
'amount_due' => 0,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->addDays(14),
]);
// Overdue invoice (due_date in the past, should not appear)
Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2026-7003',
'status' => 'pending',
'subtotal' => 39.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 39.00,
'amount_paid' => 0,
'amount_due' => 39.00,
'currency' => 'GBP',
'issue_date' => now()->subDays(30),
'due_date' => now()->subDays(7),
]);
$unpaid = $this->service->getUnpaidForWorkspace($this->workspace);
expect($unpaid)->toHaveCount(1)
->and($unpaid->first()->invoice_number)->toBe('INV-2026-7001');
});
});
describe('getOverdueForWorkspace()', function () {
it('returns pending invoices that are past due', function () {
// Overdue pending
Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2026-8001',
'status' => 'pending',
'subtotal' => 19.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 19.00,
'amount_paid' => 0,
'amount_due' => 19.00,
'currency' => 'GBP',
'issue_date' => now()->subDays(30),
'due_date' => now()->subDays(7),
]);
// Not yet due (should not appear)
Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2026-8002',
'status' => 'pending',
'subtotal' => 29.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 29.00,
'amount_paid' => 0,
'amount_due' => 29.00,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->addDays(14),
]);
// Paid (should not appear)
Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2026-8003',
'status' => 'paid',
'subtotal' => 39.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 39.00,
'amount_paid' => 39.00,
'amount_due' => 0,
'currency' => 'GBP',
'issue_date' => now()->subDays(30),
'due_date' => now()->subDays(7),
]);
$overdue = $this->service->getOverdueForWorkspace($this->workspace);
expect($overdue)->toHaveCount(1)
->and($overdue->first()->invoice_number)->toBe('INV-2026-8001');
});
});
});
describe('Invoice model', function () {
beforeEach(function () {
$this->invoice = Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2026-9001',
'status' => 'pending',
'subtotal' => 100.00,
'tax_amount' => 20.00,
'discount_amount' => 0,
'total' => 120.00,
'amount_paid' => 0,
'amount_due' => 120.00,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->addDays(14),
]);
});
describe('status helpers', function () {
it('identifies pending invoices', function () {
expect($this->invoice->isPending())->toBeTrue()
->and($this->invoice->isPaid())->toBeFalse()
->and($this->invoice->isVoid())->toBeFalse();
});
it('identifies paid invoices', function () {
$this->invoice->update(['status' => 'paid']);
expect($this->invoice->isPaid())->toBeTrue()
->and($this->invoice->isPending())->toBeFalse();
});
it('identifies void invoices', function () {
$this->invoice->update(['status' => 'void']);
expect($this->invoice->isVoid())->toBeTrue()
->and($this->invoice->isPending())->toBeFalse();
});
it('identifies overdue invoices by status', function () {
$this->invoice->update(['status' => 'overdue']);
expect($this->invoice->isOverdue())->toBeTrue();
});
it('identifies overdue invoices by past due date', function () {
$this->invoice->update(['due_date' => now()->subDays(1)]);
expect($this->invoice->isOverdue())->toBeTrue();
});
it('identifies sent invoices', function () {
$this->invoice->update(['status' => 'sent']);
expect($this->invoice->isSent())->toBeTrue()
->and($this->invoice->isPending())->toBeTrue(); // sent is considered pending
});
it('identifies draft invoices', function () {
$this->invoice->update(['status' => 'draft']);
expect($this->invoice->isDraft())->toBeTrue()
->and($this->invoice->isPending())->toBeTrue(); // draft is considered pending
});
});
describe('actions', function () {
it('marks invoice as paid with payment', function () {
$payment = Payment::create([
'workspace_id' => $this->workspace->id,
'gateway' => 'stripe',
'gateway_payment_id' => 'pi_model_test',
'amount' => 120.00,
'fee' => 3.00,
'net_amount' => 117.00,
'refunded_amount' => 0,
'currency' => 'GBP',
'status' => 'succeeded',
'paid_at' => now(),
]);
$this->invoice->markAsPaid($payment);
expect($this->invoice->status)->toBe('paid')
->and($this->invoice->payment_id)->toBe($payment->id)
->and((float) $this->invoice->amount_paid)->toBe(120.00)
->and((float) $this->invoice->amount_due)->toBe(0.00)
->and($this->invoice->paid_at)->not->toBeNull();
});
it('marks invoice as void', function () {
$this->invoice->markAsVoid();
expect($this->invoice->status)->toBe('void');
});
it('marks invoice as sent', function () {
$this->invoice->send();
expect($this->invoice->status)->toBe('sent');
});
});
describe('scopes', function () {
it('scopes to paid invoices', function () {
$this->invoice->update(['status' => 'paid']);
Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2026-9002',
'status' => 'pending',
'subtotal' => 50.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 50.00,
'amount_paid' => 0,
'amount_due' => 50.00,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->addDays(14),
]);
$paid = Invoice::paid()->get();
expect($paid)->toHaveCount(1)
->and($paid->first()->id)->toBe($this->invoice->id);
});
it('scopes to unpaid invoices', function () {
Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2026-9003',
'status' => 'paid',
'subtotal' => 50.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 50.00,
'amount_paid' => 50.00,
'amount_due' => 0,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->addDays(14),
]);
$unpaid = Invoice::unpaid()->get();
expect($unpaid)->toHaveCount(1)
->and($unpaid->first()->id)->toBe($this->invoice->id);
});
it('scopes to workspace invoices', function () {
$otherWorkspace = Workspace::factory()->create();
Invoice::create([
'workspace_id' => $otherWorkspace->id,
'invoice_number' => 'INV-2026-9004',
'status' => 'pending',
'subtotal' => 50.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 50.00,
'amount_paid' => 0,
'amount_due' => 50.00,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->addDays(14),
]);
$workspaceInvoices = Invoice::forWorkspace($this->workspace->id)->get();
expect($workspaceInvoices)->toHaveCount(1)
->and($workspaceInvoices->first()->id)->toBe($this->invoice->id);
});
});
describe('invoice number generation', function () {
it('generates invoice numbers with correct format', function () {
$number = Invoice::generateInvoiceNumber();
$year = now()->format('Y');
expect($number)->toMatch("/^INV-{$year}-\\d{4}$/");
});
it('increments invoice numbers sequentially', function () {
$number1 = Invoice::generateInvoiceNumber();
Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => $number1,
'status' => 'pending',
'subtotal' => 10.00,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 10.00,
'amount_paid' => 0,
'amount_due' => 10.00,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->addDays(14),
]);
$number2 = Invoice::generateInvoiceNumber();
$seq1 = (int) substr($number1, -4);
$seq2 = (int) substr($number2, -4);
expect($seq2)->toBe($seq1 + 1);
});
});
describe('relationships', function () {
it('belongs to a workspace', function () {
expect($this->invoice->workspace)->toBeInstanceOf(Workspace::class)
->and($this->invoice->workspace->id)->toBe($this->workspace->id);
});
it('has many items', function () {
InvoiceItem::create([
'invoice_id' => $this->invoice->id,
'description' => 'Line item 1',
'quantity' => 1,
'unit_price' => 60.00,
'line_total' => 72.00,
'tax_rate' => 20.00,
'tax_amount' => 12.00,
]);
InvoiceItem::create([
'invoice_id' => $this->invoice->id,
'description' => 'Line item 2',
'quantity' => 2,
'unit_price' => 20.00,
'line_total' => 48.00,
'tax_rate' => 20.00,
'tax_amount' => 8.00,
]);
expect($this->invoice->items)->toHaveCount(2);
});
it('belongs to an order', function () {
$order = Order::create([
'orderable_type' => Workspace::class,
'orderable_id' => $this->workspace->id,
'user_id' => $this->user->id,
'order_number' => 'ORD-20260324-REL001',
'status' => 'paid',
'type' => 'subscription',
'currency' => 'GBP',
'subtotal' => 100.00,
'tax_amount' => 20.00,
'discount_amount' => 0,
'total' => 120.00,
]);
$this->invoice->update(['order_id' => $order->id]);
$this->invoice->refresh();
expect($this->invoice->order)->toBeInstanceOf(Order::class)
->and($this->invoice->order->id)->toBe($order->id);
});
it('belongs to a payment', function () {
$payment = Payment::create([
'workspace_id' => $this->workspace->id,
'gateway' => 'stripe',
'gateway_payment_id' => 'pi_rel_test',
'amount' => 120.00,
'fee' => 0,
'net_amount' => 120.00,
'refunded_amount' => 0,
'currency' => 'GBP',
'status' => 'succeeded',
]);
$this->invoice->update(['payment_id' => $payment->id]);
$this->invoice->refresh();
expect($this->invoice->payment)->toBeInstanceOf(Payment::class)
->and($this->invoice->payment->id)->toBe($payment->id);
});
});
describe('issued_at accessor', function () {
it('returns issue_date as issued_at alias', function () {
expect($this->invoice->issued_at)->not->toBeNull()
->and($this->invoice->issued_at->toDateString())
->toBe($this->invoice->issue_date->toDateString());
});
});
});
afterEach(function () {
Mockery::close();
});