Files
portal-api/tests/Feature/Api/Admin/VatAutomationTest.php
T

178 lines
6.6 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin;
use App\Enums\Invoice\InvoiceStatus;
use App\Mail\InvoiceNotificationMail;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use App\Models\Product;
use App\Models\Setting;
use App\Models\User;
use App\Support\VatCalculator;
use App\Support\VatNumbering;
use App\Support\VatSettings;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class VatAutomationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
VatSettings::forget();
}
private function rates(array $rates): void
{
Setting::updateOrCreate(['key' => 'vat_settings'], ['value' => json_encode(
collect($rates)->map(fn ($rate, $type) => ['type' => $type, 'type_label' => $type, 'vat_rate' => $rate])->values()
)]);
VatSettings::forget();
}
private function invoice(array $lines, int $discount = 0, int $status = InvoiceStatus::UNPAID->value): Invoice
{
$customer = Customer::factory()->create();
$gross = array_sum(array_column($lines, 1));
$invoice = Invoice::create([
'invoice_number' => 'INV-' . uniqid(), 'customer_id' => $customer->id, 'invoice_type' => 1,
'sub_total' => $gross, 'discount_amount' => $discount, 'tax' => 0, 'total' => $gross - $discount, 'status' => $status,
]);
foreach ($lines as [$type, $amount]) {
InvoiceItem::create([
'invoice_id' => $invoice->id, 'product_id' => Product::factory()->create(['product_type' => $type])->id,
'type' => 'service', 'description' => $type, 'months' => 1, 'quantity' => 1,
'unit_price' => $amount, 'total_price' => $amount,
]);
}
return $invoice;
}
private function admin(): User
{
$role = Role::create(['name' => 'role-' . uniqid(), 'guard_name' => 'admin']);
foreach (['invoice.view', 'invoice.edit', 'setting.manager'] as $name) {
Permission::firstOrCreate(['name' => $name, 'guard_name' => 'admin']);
$role->givePermissionTo($name);
}
return tap(User::factory()->create())->assignRole($role);
}
public function test_tax_is_extracted_from_inclusive_price_per_service_type(): void
{
$this->rates(['vps' => 10, 'domain' => 0]);
$invoice = $this->invoice([['vps', 110_000], ['domain', 500_000]]);
VatCalculator::refresh($invoice);
$this->assertSame(10_000, (int) $invoice->fresh()->tax);
$this->assertSame(610_000, (int) $invoice->fresh()->total);
}
public function test_discount_reduces_taxable_amount_proportionally(): void
{
$this->rates(['vps' => 10]);
$invoice = $this->invoice([['vps', 220_000]], discount: 110_000);
VatCalculator::refresh($invoice);
$this->assertSame(10_000, (int) $invoice->fresh()->tax);
}
public function test_no_configured_rate_keeps_tax_zero(): void
{
$invoice = $this->invoice([['vps', 110_000]]);
VatCalculator::refresh($invoice);
$this->assertSame(0, (int) $invoice->fresh()->tax);
}
public function test_issued_invoice_tax_is_frozen(): void
{
$this->rates(['vps' => 10]);
$invoice = $this->invoice([['vps', 110_000]]);
VatNumbering::issue($invoice);
$this->rates(['vps' => 8]);
VatCalculator::refresh($invoice->fresh());
$this->assertSame(10_000, (int) $invoice->fresh()->tax);
}
public function test_issue_assigns_sequential_numbers_and_skips_manual_collisions(): void
{
VatSettings::saveNumbering(['serial' => '1C26TCP', 'next_number' => 5, 'padding' => 4, 'auto_issue' => false]);
$first = $this->invoice([['vps', 100_000]]);
$second = $this->invoice([['vps', 100_000]]);
$manual = $this->invoice([['vps', 100_000]]);
$manual->update(['vat_serial' => '1C26TCP', 'vat_number' => '0006']);
$a = VatNumbering::issue($first);
$b = VatNumbering::issue($second);
$this->assertSame('0005', $a->vat_number);
$this->assertSame('0007', $b->vat_number);
$this->assertSame(8, VatSettings::numbering()['next_number']);
$this->assertSame($a->vat_number, VatNumbering::issue($a)->vat_number);
$this->assertNotNull($a->buyer_snapshot);
}
public function test_auto_issue_runs_only_when_enabled_and_invoice_becomes_paid(): void
{
VatSettings::saveNumbering(['serial' => 'AB', 'next_number' => 1, 'padding' => 3, 'auto_issue' => true]);
$invoice = $this->invoice([['vps', 100_000]]);
$this->assertNull($invoice->fresh()->vat_number);
$invoice->update(['status' => InvoiceStatus::PAID->value]);
$this->assertSame('001', $invoice->fresh()->vat_number);
VatSettings::saveNumbering(['serial' => 'AB', 'next_number' => 2, 'padding' => 3, 'auto_issue' => false]);
$other = $this->invoice([['vps', 100_000]]);
$other->update(['status' => InvoiceStatus::PAID->value]);
$this->assertNull($other->fresh()->vat_number);
}
public function test_admin_can_issue_and_configure_numbering(): void
{
$admin = $this->admin();
$invoice = $this->invoice([['vps', 100_000]]);
$this->actingAs($admin, 'admin')->putJson('/api/v1/administrator/vat-numbering', [
'serial' => '1c26tcp', 'next_number' => 10, 'padding' => 6, 'auto_issue' => false,
])->assertOk()->assertJsonPath('data.serial', '1C26TCP');
$this->actingAs($admin, 'admin')->postJson("/api/v1/administrator/invoices/{$invoice->id}/vat/issue")
->assertOk()->assertJsonPath('data.vat_number', '000010')->assertJsonPath('data.vat_serial', '1C26TCP');
$this->actingAs($admin, 'admin')->postJson("/api/v1/administrator/invoices/{$invoice->id}/vat/issue")
->assertStatus(422);
$this->actingAs($admin, 'admin')->putJson('/api/v1/administrator/vat-numbering', [
'serial' => '1C26TCP', 'next_number' => 10, 'padding' => 6, 'auto_issue' => false,
])->assertStatus(422);
}
public function test_invoice_notification_email_carries_lookup_code(): void
{
$this->seed(\Database\Seeders\TemplateSeeder::class);
$invoice = $this->invoice([['vps', 100_000]]);
$html = InvoiceNotificationMail::forInvoice($invoice->load('customer'))->render();
$this->assertStringContainsString($invoice->lookup_code, $html);
$this->assertStringContainsString('/tra-cuu-hoa-don', $html);
}
}