diff --git a/tests/Feature/InvoiceServiceTest.php b/tests/Feature/InvoiceServiceTest.php new file mode 100644 index 0000000..ce116d4 --- /dev/null +++ b/tests/Feature/InvoiceServiceTest.php @@ -0,0 +1,1161 @@ +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(); +});