fixture = DocumentPreparationFixture::create(); $this->login(); $this->client = $this->writeA('clients', ['name' => 'Cliente documental sintético'])->assertCreated()->json('id'); $this->service = $this->writeA('services', ['name' => 'Serviço documental sintético'])->assertCreated()->json('id'); $this->source = $this->writeA('service_drafts', ['client_id' => $this->client, 'lines' => [['service_id' => $this->service, 'quantity' => '1.25', 'unit_price' => '0.00000001']]])->assertCreated()->json('id'); $this->billing = $this->writeA('billing_drafts', ['source_id' => $this->source])->assertCreated()->json('id'); } private function login(string $kind = 'manager'): void { $this->postJson('/api/login', ['email' => $this->fixture[$kind]->email, 'password' => $this->fixture['password']])->assertOk(); $this->select($this->fixture['unit']); } private function select(?int $unit): void { $this->token = $this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['company'], 'unit_id' => $unit])->assertOk()->json('context.token'); } private function writeA(string $area, array $data, ?int $id = null) { $this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => (string) Str::uuid()]); return $id ? $this->patchJson("/api/a/$area/$id", $data) : $this->postJson("/api/a/$area", $data); } private function save(string $kind, int $id, array $data) { return $this->withHeader('X-SGF-Context', $this->token)->patchJson("/api/document-preparation/$kind/$id", $data); } private function profile(): array { return ['version' => 0, 'nuit' => 'NUIT-SINTETICO', 'address' => 'Morada de teste', 'locality' => 'Local sintético', 'country' => 'País de teste']; } private function preparation(int $draftVersion = 1): array { return ['version' => 0, 'draft_version' => $draftVersion, 'currency' => null, 'service_date' => '2026-11-01', 'period_start' => '2026-11-01', 'period_end' => '2026-11-30', 'payment_terms' => 'Prazo a conferir', 'proposed_due_date' => '2026-11-15', 'price_mode' => 'undefined', 'units' => ['hora'], 'payment_instruction_ids' => []]; } private function payment(): array { return ['version' => 0, 'label' => 'Instruções sintéticas', 'bank' => 'Banco fictício', 'holder' => 'Titular sintético', 'account_number' => 'SYNTHETIC-'.Str::uuid(), 'currency' => null, 'instructions' => 'Apenas simulação, não executar pagamentos.']; } private function confirm(): array { return $this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => (string) Str::uuid()])->postJson("/api/b1/billing-drafts/$this->billing/confirm-internal", ['version' => 1])->assertOk()->json(); } private function confirmation(): array { return $this->getJson("/api/a/billing_drafts/$this->billing")->assertOk()->json('confirmation'); } public function test_client_starts_with_name_and_existing_names_are_reused_without_duplicate_fields(): void { $record = $this->getJson("/api/document-preparation/client/$this->client")->assertOk(); $record->assertJsonPath('version', 0)->assertJsonPath('name', 'Cliente documental sintético'); $this->save('client', $this->client, $this->profile())->assertOk(); $this->writeA('clients', ['name' => 'Nome revisto', 'version' => 1], $this->client)->assertOk(); $record = $this->getJson("/api/document-preparation/client/$this->client")->assertOk(); $record->assertJsonPath('name', 'Nome revisto')->assertJsonPath('data.nuit', 'NUIT-SINTETICO'); $this->assertArrayNotHasKey('name', $record->json('data')); $this->assertSame(1, DB::table('a_clients')->count()); } public function test_issuer_and_payment_instructions_are_company_scoped_and_audited_without_values(): void { $this->save('issuer', $this->fixture['company'], $this->profile())->assertOk(); $payment = $this->payment(); $id = $this->save('payment', 0, $payment)->assertOk()->json('id'); $this->getJson("/api/document-preparation/payment/$id")->assertOk()->assertJsonPath('data.account_number', $payment['account_number']); $this->assertStringNotContainsString($payment['account_number'], DB::table('identity_audit')->pluck('details')->implode('')); $this->assertSame(2, DB::table('doc_history')->count()); $this->select(null); $this->getJson('/api/document-preparation/payment')->assertOk()->assertJsonCount(1, 'records'); } public function test_preparation_selects_instructions_without_receipt_and_preserves_exact_prices(): void { $id = $this->save('payment', 0, $this->payment())->assertOk()->json('id'); $data = $this->preparation(); $data['payment_instruction_ids'] = [$id]; $data['price_mode'] = 'included'; $this->save('billing_drafts', $this->billing, $data)->assertOk(); $this->getJson("/api/a/billing_drafts/$this->billing")->assertOk()->assertJsonPath('base_amount', '0.0000000125')->assertJsonPath('data.currency', null)->assertJsonPath('version', 1); $record = $this->getJson("/api/document-preparation/billing_drafts/$this->billing")->assertOk(); $record->assertJsonPath('data.payment_instruction_ids.0', $id)->assertJsonPath('data.price_mode', 'included'); $this->assertFalse(\Illuminate\Support\Facades\Schema::hasTable('receipts')); $this->assertFalse(\Illuminate\Support\Facades\Schema::hasTable('journal_entries')); } public function test_preparation_reuses_source_dates_units_and_instructions_for_new_billing(): void { $id = $this->save('payment', 0, $this->payment())->assertOk()->json('id'); $data = $this->preparation(); $data['payment_instruction_ids'] = [$id]; $this->save('service_drafts', $this->source, $data)->assertOk(); $billing = $this->writeA('billing_drafts', ['source_id' => $this->source])->assertCreated()->json('id'); $record = $this->getJson("/api/document-preparation/billing_drafts/$billing")->assertOk(); $record->assertJsonPath('data.units.0', 'hora')->assertJsonPath('data.service_date', '2026-11-01')->assertJsonPath('data.payment_instruction_ids.0', $id)->assertJsonPath('data.source_preparation.version', 1); } public function test_completeness_distinguishes_missing_filled_and_pending_without_false_validation(): void { $before = $this->getJson("/api/document-preparation/client/$this->client")->assertOk()->json(); $this->assertSame('missing', collect($before['review'])->firstWhere('field', 'nuit')['state']); $this->save('client', $this->client, $this->profile())->assertOk(); $after = $this->getJson("/api/document-preparation/client/$this->client")->assertOk()->json(); $this->assertSame('filled_not_validated', collect($after['review'])->firstWhere('field', 'nuit')['state']); $this->assertSame('validation_pending', collect($after['review'])->firstWhere('field', 'legal')['state']); $data = $this->preparation(); $data['payment_terms'] = 'Pagamento em 30 dias'; $data['proposed_due_date'] = '2026-11-02'; $this->save('billing_drafts', $this->billing, $data)->assertOk(); $record = $this->getJson("/api/document-preparation/billing_drafts/$this->billing")->assertOk(); $record->assertJsonPath('data.proposed_due_date', '2026-11-02'); $this->assertSame('validation_pending', collect($record->json('review'))->firstWhere('field', 'terms_due')['state']); } public function test_reader_can_consult_but_not_change_any_new_area(): void { $payment = $this->save('payment', 0, $this->payment())->assertOk()->json('id'); $this->login('reader'); foreach (['issuer' => $this->fixture['company'], 'client' => $this->client, 'payment' => $payment, 'service_drafts' => $this->source, 'billing_drafts' => $this->billing] as $kind => $id) { $this->getJson("/api/document-preparation/$kind/$id")->assertOk(); $this->save($kind, $id, ['version' => 0])->assertForbidden(); } } public function test_no_implicit_new_rights_and_revocation_affects_next_request(): void { DB::table('permission_grants')->where('user_id', $this->fixture['manager']->id)->where('permission', 'issuer_documents.view')->delete(); $this->getJson('/api/document-preparation/payment')->assertForbidden(); $this->getJson('/api/document-preparation/issuer/'.$this->fixture['company'])->assertForbidden(); $this->getJson('/api/a/clients')->assertOk(); } public function test_cross_company_same_tenant_and_cross_unit_are_refused(): void { $id = $this->save('payment', 0, $this->payment())->assertOk()->json('id'); IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['sibling'], 'company.view'); foreach (['issuer_documents.view','issuer_documents.edit'] as $permission) { IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['sibling'], $permission); } $this->token = $this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['sibling'], 'unit_id' => null])->assertOk()->json('context.token'); $this->getJson("/api/document-preparation/payment/$id")->assertForbidden(); $this->getJson('/api/document-preparation/issuer/'.$this->fixture['company'])->assertForbidden(); $this->save('payment', $id, $this->payment())->assertForbidden(); $this->select($this->fixture['unit']); IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], 'unit.view', $this->fixture['hidden']); foreach (['draft_documents.view','draft_documents.edit','billing_drafts.view'] as $permission) { IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], $permission, $this->fixture['hidden']); } $this->select($this->fixture['hidden']); $this->getJson("/api/document-preparation/billing_drafts/$this->billing")->assertForbidden(); } public function test_foreign_payment_selection_and_protected_fields_are_refused(): void { $foreign = DB::table('doc_records')->insertGetId(['tenant_id' => $this->fixture['otherTenant'], 'company_id' => $this->fixture['foreign'], 'unit_id' => null, 'kind' => 'payment', 'record_id' => 0, 'version' => 1, 'data' => json_encode(['label' => 'Outra empresa']), 'created_at' => now(), 'updated_at' => now()]); $data = $this->preparation(); $data['payment_instruction_ids'] = [$foreign]; $this->save('billing_drafts', $this->billing, $data)->assertForbidden(); $this->save('issuer', $this->fixture['company'], $this->profile() + ['company_id' => $this->fixture['sibling']])->assertUnprocessable(); $this->save('payment', 0, $this->payment() + ['password' => 'not-collected'])->assertUnprocessable(); } public function test_old_profile_draft_version_and_context_are_refused(): void { $this->save('client', $this->client, $this->profile())->assertOk(); $this->save('client', $this->client, $this->profile())->assertStatus(409); $this->save('billing_drafts', $this->billing, $this->preparation(2))->assertStatus(409); $old = $this->token; $this->select(null); $this->withHeader('X-SGF-Context', $old)->patchJson('/api/document-preparation/issuer/'.$this->fixture['company'], $this->profile())->assertStatus(409); } public function test_currency_is_reused_and_synchronised_without_recalculating_tax(): void { $data = $this->preparation(); $data['currency'] = 'TST'; $this->save('billing_drafts', $this->billing, $data)->assertOk(); $this->getJson("/api/a/billing_drafts/$this->billing")->assertOk()->assertJsonPath('version', 2)->assertJsonPath('data.currency', 'TST')->assertJsonPath('base_amount', '0.0000000125')->assertJsonPath('data.tax_status', 'pending'); $this->getJson("/api/document-preparation/billing_drafts/$this->billing")->assertOk()->assertJsonPath('data.draft_version', 2); } public function test_existing_v1_confirmation_is_unchanged_after_profile_and_payment_updates(): void { $this->confirm(); $before = $this->confirmation(); $this->assertArrayNotHasKey('schema_version', $before['snapshot']); $this->save('issuer', $this->fixture['company'], $this->profile())->assertOk(); $this->save('client', $this->client, $this->profile())->assertOk(); $this->save('payment', 0, $this->payment())->assertOk(); $this->assertSame($before, $this->confirmation()); $this->save('billing_drafts', $this->billing, $this->preparation())->assertStatus(409); } public function test_future_confirmation_v2_preserves_selected_instruction_version_and_blocks_unauthorized_read(): void { $this->save('issuer', $this->fixture['company'], $this->profile())->assertOk(); $this->save('client', $this->client, $this->profile())->assertOk(); $payment = $this->payment(); $id = $this->save('payment', 0, $payment)->assertOk()->json('id'); $data = $this->preparation(); $data['payment_instruction_ids'] = [$id]; $this->save('billing_drafts', $this->billing, $data)->assertOk(); $this->confirm(); $before = $this->confirmation(); $this->assertSame(2, $before['snapshot']['schema_version']); $this->assertDatabaseHas('a_events', ['id' => $before['event']['id'], 'schema_version' => 2]); $this->assertSame($payment['account_number'], $before['snapshot']['document_preparation']['payment_instructions'][0]['data']['account_number']); $payment['version'] = 1; $payment['instructions'] = 'Texto sintético revisto'; $this->save('payment', $id, $payment)->assertOk(); $this->assertSame($before, $this->confirmation()); $this->assertStringNotContainsString($payment['account_number'], DB::table('identity_audit')->pluck('details')->implode('')); DB::table('permission_grants')->where('user_id', $this->fixture['manager']->id)->where('permission', 'issuer_documents.view')->delete(); $this->getJson("/api/a/billing_drafts/$this->billing")->assertForbidden(); } public function test_invalid_dates_mode_and_precision_are_rejected(): void { $data = $this->preparation(); $data['period_end'] = '2026-10-31'; $this->save('billing_drafts', $this->billing, $data)->assertUnprocessable(); $data = $this->preparation(); $data['price_mode'] = 'tax-zero'; $this->save('billing_drafts', $this->billing, $data)->assertUnprocessable(); $this->writeA('billing_drafts', ['version' => 1, 'lines' => [['service_id' => $this->service, 'quantity' => '1', 'unit_price' => '0.123456789']]], $this->billing)->assertUnprocessable(); } public function test_sql_mass_scope_transfer_is_blocked(): void { $this->save('issuer', $this->fixture['company'], $this->profile())->assertOk(); $this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_DOC_IMMUTABLE_SCOPE'); DB::table('doc_records')->update(['company_id' => $this->fixture['sibling'], 'version' => 2]); } public function test_history_is_append_only(): void { $this->save('issuer', $this->fixture['company'], $this->profile())->assertOk(); $this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_APPEND_ONLY_AUDIT'); DB::table('doc_history')->update(['snapshot' => '{}']); } public function test_failure_rolls_back_and_does_not_expose_instruction_values(): void { DB::unprepared("CREATE OR REPLACE FUNCTION sgf_doc_test_fail() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'SYNTHETIC_DOC_FAILURE'; END; \$\$; CREATE TRIGGER synthetic_doc_fail BEFORE INSERT ON doc_history FOR EACH ROW EXECUTE FUNCTION sgf_doc_test_fail();"); $payment = $this->payment(); $response = $this->save('payment', 0, $payment)->assertStatus(500); $this->assertStringNotContainsString($payment['account_number'], $response->getContent()); $this->assertSame(0, DB::table('doc_records')->count()); $this->assertSame(0, DB::table('doc_history')->count()); $log = storage_path('logs/laravel.log'); $this->assertFalse(is_file($log) && str_contains(file_get_contents($log), $payment['account_number']), 'Instruction values must not appear in technical logs.'); } public function test_preparation_becomes_stale_after_a_draft_edit_and_is_not_silently_reinterpreted(): void { $this->save('billing_drafts', $this->billing, $this->preparation())->assertOk(); $this->writeA('billing_drafts', ['version' => 1, 'description' => 'Descrição revista'], $this->billing)->assertOk(); $record = $this->getJson("/api/document-preparation/billing_drafts/$this->billing")->assertOk(); $this->assertSame('stale', collect($record->json('review'))->firstWhere('field', 'draft_version')['state']); $this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => (string) Str::uuid()])->postJson("/api/b1/billing-drafts/$this->billing/confirm-internal", ['version' => 2])->assertStatus(409); $data = $this->preparation(2); $data['version'] = 1; $this->save('billing_drafts', $this->billing, $data)->assertOk(); $this->getJson("/api/a/billing_drafts/$this->billing")->assertOk()->assertJsonPath('base_amount', '0.0000000125'); } }