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>
1161 lines
42 KiB
PHP
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();
|
|
});
|