File: /home/xedaptot/ai.naniguide.com/tests/Feature/Admin/InvoiceServiceTest.php
<?php
/**
* InvoiceService — read aggregator for the admin invoice screen.
*
* Covers:
* - dashboardStats() returns counts + paid revenue (joined through orders→order_items)
* - list() honours filter DTO (type, status, keyword, customer_uid, date range, sort)
* - sidebarStats() shape contract (5 widgets) without requiring a populated DB
*/
use App\Dto\Refactor\Invoice\InvoiceListFilterDto;
use App\Dto\Refactor\Invoice\InvoiceSidebarStatsDto;
use App\Library\OrderFulfillment\OrderItemTypes;
use App\Model\Invoice;
use App\Model\OrderItem;
use App\Services\Admin\InvoiceService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
uses(TestCase::class, DatabaseTransactions::class);
beforeEach(function () {
$this->service = new InvoiceService();
});
/* ─────────── dashboardStats ─────────── */
it('dashboardStats returns the documented integer counts plus a numeric revenue', function () {
$stats = $this->service->dashboardStats();
expect($stats)
->toHaveKeys(['total', 'paid', 'unpaid', 'pending', 'revenue'])
->and($stats['total'])->toBeInt()
->and($stats['paid'])->toBeInt()
->and($stats['unpaid'])->toBeInt()
->and($stats['pending'])->toBeInt()
->and($stats['revenue'])->toBeFloat();
});
it('dashboardStats revenue sums order_items.amount across paid invoices only', function () {
$baselinePaid = Invoice::paid()->count();
$baselineRevenue = (float) $this->service->dashboardStats()['revenue'];
$invoice = Invoice::factory()->paid()->create();
OrderItem::factory()->create(['order_id' => $invoice->order_id, 'amount' => 100.00]);
$after = $this->service->dashboardStats();
expect($after['paid'])->toBe($baselinePaid + 1);
// Two items now (29.99 from the factory + 100.00 we just added) = 129.99 added.
expect($after['revenue'])->toEqualWithDelta($baselineRevenue + 129.99, 0.01);
});
/* ─────────── list() with DTO filters ─────────── */
it('list() filters by type code', function () {
$sub = Invoice::factory()->create();
OrderItem::factory()->create(['order_id' => $sub->order_id, 'type' => OrderItemTypes::SUBSCRIPTION_NEW]);
$fee = Invoice::factory()->create();
OrderItem::factory()->create(['order_id' => $fee->order_id, 'type' => OrderItemTypes::FEE_CUSTOM]);
$dto = new InvoiceListFilterDto(type: OrderItemTypes::SUBSCRIPTION_NEW);
$page = $this->service->list($dto);
$ids = collect($page->items())->pluck('id')->all();
expect($ids)->toContain($sub->id)->not->toContain($fee->id);
});
it('list() type filter matches both modern code and legacy FQCN aliases', function () {
// Pre-refactor: row stored full FQCN class name on order_items.type.
$legacy = Invoice::factory()->create();
OrderItem::factory()->create([
'order_id' => $legacy->order_id,
'type' => 'App\\Model\\OrderItemNewSubscription', // legacy raw value
]);
// Post-refactor: row stored modern short code.
$modern = Invoice::factory()->create();
OrderItem::factory()->create([
'order_id' => $modern->order_id,
'type' => OrderItemTypes::SUBSCRIPTION_NEW,
]);
// A different type — must NOT come back.
$other = Invoice::factory()->create();
OrderItem::factory()->create([
'order_id' => $other->order_id,
'type' => OrderItemTypes::FEE_CUSTOM,
]);
// Selecting the modern dropdown option should match BOTH formats.
$dto = new InvoiceListFilterDto(type: OrderItemTypes::SUBSCRIPTION_NEW);
$ids = collect($this->service->list($dto)->items())->pluck('id')->all();
// Legacy FQCN row should still match the modern code via alias.
expect($ids)->toContain($legacy->id);
// Modern short-code row should match exactly.
expect($ids)->toContain($modern->id);
// Unrelated type must not leak in.
expect($ids)->not->toContain($other->id);
});
it('list() respects status filter (paid)', function () {
$paid = Invoice::factory()->paid()->create();
$new = Invoice::factory()->create();
$dto = new InvoiceListFilterDto(status: 'paid');
$ids = collect($this->service->list($dto)->items())->pluck('id')->all();
expect($ids)->toContain($paid->id)->not->toContain($new->id);
});
it('list() applies keyword search across uid/number/billing_email', function () {
$invoice = Invoice::factory()->create([
'billing_email' => 'needle-' . uniqid() . '@example.com',
]);
$dto = new InvoiceListFilterDto(keyword: 'needle-');
$ids = collect($this->service->list($dto)->items())->pluck('id')->all();
expect($ids)->toContain($invoice->id);
});
it('list() rejects an out-of-range per_page silently and falls back to 15', function () {
$request = \Illuminate\Http\Request::create('/admin/invoices/listing', 'GET', ['per_page' => 9999]);
$dto = InvoiceListFilterDto::fromRequest($request);
expect($dto->perPage)->toBe(15);
});
it('list() defaults sort_direction to desc on bogus input', function () {
$request = \Illuminate\Http\Request::create('/admin/invoices/listing', 'GET', [
'sort_order' => 'invoices.created_at',
'sort_direction' => 'sideways',
]);
$dto = InvoiceListFilterDto::fromRequest($request);
expect($dto->sortDirection)->toBe('desc');
});
/* ─────────── sidebarStats() ─────────── */
it('sidebarStats() returns the 5-widget DTO shape', function () {
$bundle = $this->service->sidebarStats();
expect($bundle)->toBeInstanceOf(InvoiceSidebarStatsDto::class);
expect($bundle->pendingApproval)->toHaveKeys(['count', 'oldest_days', 'first_uid']);
expect($bundle->failedRecent)->toBeArray();
expect($bundle->topCustomers30d)->toBeArray();
expect($bundle->recentActivity)->toBeArray();
expect($bundle->revenueTrend)->toHaveKeys(['revenue30d', 'revenuePrev30d', 'deltaPct', 'currency', 'paid_count_30d']);
});
it('sidebarStats() pendingApproval counts current pending invoices', function () {
// Use baseline diffs — DB may already have demo seed rows.
$baseline = $this->service->sidebarStats()->pendingApproval['count'];
$inv = Invoice::factory()->create();
\App\Model\PaymentIntent::factory()->create([
'invoice_id' => $inv->id,
'status' => \App\Model\PaymentIntent::STATUS_PENDING,
]);
$bundle = $this->service->sidebarStats();
expect($bundle->pendingApproval['count'])->toBeGreaterThanOrEqual($baseline + 1);
});