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