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(); });