feat(iva): implement G3 temporary benefit cessation and explicit treatment continuity

This commit is contained in:
2026-10-24 16:51:52 +02:00
parent 908c8afc54
commit ac948bd8b7
6 changed files with 191 additions and 3 deletions
@@ -0,0 +1,52 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IvaTemporaryBenefitDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_cessation_and_explicit_continuations_recover_with_their_opportunity_and_history(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::factory()->create(['email_verified_at' => now()]));
$draft = json_decode(file_get_contents(base_path('outputs/iva-l1-g3-temporary-20261024/calculated.json')), true, flags: JSON_THROW_ON_ERROR);
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => [], 'economic_snapshot' => $draft], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$recovered = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($draft['modelo_a_snapshot'], $recovered['modelo_a_snapshot']);
$this->assertSame('opportunity:322', $recovered['context_opportunity']);
$this->assertSame('Manica', $recovered['selection_labels']['province_id']);
$store = json_decode($recovered['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR);
$book = $store['salesOperations']['opportunity:322'];
$this->assertSame('opportunity:322', $book['result']['opportunity']);
$this->assertSame('MZN', $book['result']['currency']);
$this->assertSame('2026-10', $book['result']['periods'][0]['period']);
$this->assertSame('700.00', $book['result']['periods'][0]['fields']['01']);
$this->assertSame('112.00', $book['result']['periods'][0]['fields']['02']);
$this->assertSame('100.00', $book['result']['periods'][0]['fields']['05']);
$this->assertCount(8, $book['rows']);
$this->assertSame('116', $book['rows'][0]['amount']);
$this->assertSame('included', $book['rows'][0]['basis']);
$this->assertSame('temporary_sugar', $book['rows'][0]['temporaryBenefitOrigin']);
$this->assertSame('temporary_solar_inputs', $book['rows'][6]['temporaryBenefitOrigin']);
$this->assertSame('production_agriculture', $book['rows'][7]['treatment']);
$this->assertStringContainsString('P045', $book['rows'][6]['source']);
$this->assertCount(7, $book['history'][0]['rows']);
$this->assertNull($book['history'][0]['periods'][0]['fields']['01']);
$this->assertStringContainsString('terminou em31/12/2025', $book['history'][0]['rows'][0]['reason']);
$frozen = json_decode(file_get_contents(base_path('tests/frontend/fixtures/iva-annex-i-inputs.json')), true, flags: JSON_THROW_ON_ERROR);
foreach (json_decode($frozen['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['salesOperations'] as $context => $original) {
$this->assertSame($original, $store['salesOperations'][$context]);
}
file_put_contents(base_path('outputs/iva-l1-g3-temporary-20261024/recovered.json'), json_encode($recovered, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,90 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { temporaryBenefits, temporaryBenefit, temporaryBenefitNotice, temporaryBenefitVersion } from '../../resources/js/lib/iva-temporary-benefits.ts';
import { continueTemporaryOperation, evaluateSalesOperation, newSalesOperation, operationTreatments, recordSales, salesBook, salesCurrent, saveSalesBook } from '../../resources/js/lib/iva-operations.ts';
import { createProjectDossierReport } from '../../resources/js/lib/project-dossier-report.ts';
const frozen = JSON.parse(fs.readFileSync(new URL('./fixtures/iva-annex-i-inputs.json', import.meta.url), 'utf8'));
function operation(benefit, index = 0) {
return { ...newSalesOperation(`g3-${index}`, '2026-10', 'MZN'), treatment: benefit.id,
description: `SYNTHETIC QA — ${benefit.branch} / ${benefit.label}`, establishment: 'SYNTHETIC QA estabelecimento',
amount: index === 0 ? '116' : '100', basis: index === 0 ? 'included' : 'net',
evidence: 'SYNTHETIC QA — natureza e contraprestação pressupostas para demonstração; não certificação' };
}
export function fixture() {
const draft = { ...structuredClone(frozen), project_title: 'SYNTHETIC QA — G3 / Cessação de benefícios temporários', context_opportunity: 'opportunity:322' };
const pending = recordSales(draft, { currency: 'MZN', rows: temporaryBenefits.map(operation), periods: [{ period: '2026-10', complete: true }] });
const rows = pending.rows.map(row => {
const next = continueTemporaryOperation(row, 'general');
next.confirmed = operationTreatments.find(rule => rule.id === next.treatment).conditions.map((_, i) => i);
return next;
});
const agricultural = continueTemporaryOperation({ ...operation(temporaryBenefits[5], 8), id: 'g3-cane-independent' }, 'production_agriculture');
agricultural.confirmed = operationTreatments.find(rule => rule.id === agricultural.treatment).conditions.map((_, i) => i);
rows.push(agricultural);
const book = recordSales(draft, { ...pending, rows });
draft.modelo_a_snapshot = saveSalesBook(draft, book);
return { draft, book, pending };
}
test('G3 has seven stable branches; expired benefits never become zero-rated or taxable automatically in2026', () => {
assert.deepEqual(temporaryBenefits.map(item => item.branch), ['P038', 'P039', 'P040', 'P041', 'P042', 'P043', 'P045']);
for (const benefit of temporaryBenefits) {
const row = operation(benefit), result = evaluateSalesOperation(row, 'MZN');
assert.equal(result.status, 'indeterminate');
for (const field of ['field', 'rate', 'net', 'vat', 'gross']) assert.equal(result[field], null, field);
assert.match(result.reason, /terminou em31\/12\/2025/);
assert.match(result.source, /Lei10\/2025/);
for (const period of ['2026-01', '2026-12']) assert.match(temporaryBenefitNotice(benefit.id, period), /não é aplicável/);
for (const period of ['2025-12', '2027-01', '']) assert.match(evaluateSalesOperation({ ...row, period }, 'MZN').reason, /fora da cobertura/);
assert.match(evaluateSalesOperation({ ...row, amount: '', evidence: '' }, 'MZN').reason, /ficam por determinar/);
}
assert.match(temporaryBenefits[1].historicalScope, /efectuadas pela indústria/);
assert.match(temporaryBenefits[1].historicalScope, /9\(14\)/);
assert.match(temporaryBenefits[5].historicalScope, /9\(14\)/);
assert.throws(() => temporaryBenefitNotice('unknown', '2026-10'));
});
test('explicit continuation keeps amounts/origin/identity and requires new conditions; cane has an independent agricultural route', () => {
for (const [i, benefit] of temporaryBenefits.entries()) {
const row = operation(benefit, i), next = continueTemporaryOperation(row, 'general');
for (const field of ['id', 'period', 'description', 'establishment', 'amount', 'basis', 'evidence', 'sourceAmount', 'sourcePeriod', 'sourceCurrency']) assert.deepEqual(next[field], row[field], field);
assert.equal(next.temporaryBenefitOrigin, benefit.id); assert.deepEqual(next.confirmed, []);
assert.ok(next.source.startsWith(row.source)); assert.ok(next.source.includes(benefit.branch));
assert.ok(next.source.includes(temporaryBenefitVersion));
assert.equal(evaluateSalesOperation(next, 'MZN').vat, null);
const confirmed = { ...next, confirmed: [0, 1] };
assert.equal(evaluateSalesOperation(confirmed, 'MZN').vat, '16.00');
assert.equal(evaluateSalesOperation({ ...confirmed, amount: '' }, 'MZN').vat, null);
assert.equal(evaluateSalesOperation({ ...confirmed, evidence: '' }, 'MZN').vat, null);
assert.equal(evaluateSalesOperation({ ...confirmed, sourceCurrency: 'USD' }, 'MZN').vat, null);
assert.throws(() => continueTemporaryOperation(row, 'missing'));
assert.throws(() => continueTemporaryOperation(row, benefit.id));
}
const { book } = fixture();
assert.equal(book.result.rows.at(-1).field, '05'); assert.equal(book.result.rows.at(-1).vat, '0.00');
assert.equal(book.result.rows[0].net, '100.00'); assert.equal(book.result.rows[0].vat, '16.00');
assert.equal(book.rows[0].amount, '116'); assert.equal(book.rows[0].basis, 'included');
});
test('actual cessation→choice→calculation→stored snapshot→report preserves all13 delivered books and history', () => {
const { draft, book, pending } = fixture();
for (const value of Object.values(pending.result.periods[0].fields)) assert.equal(value, null);
assert.deepEqual(book.history.at(-1), pending.result);
assert.equal(book.result.periods[0].fields['01'], '700.00');
assert.equal(book.result.periods[0].fields['02'], '112.00');
assert.equal(book.result.periods[0].fields['05'], '100.00');
assert.equal(salesCurrent(draft, book), true);
assert.deepEqual(salesBook(JSON.parse(JSON.stringify(draft))), book);
const before = JSON.parse(frozen.modelo_a_snapshot).salesOperations, after = JSON.parse(draft.modelo_a_snapshot).salesOperations;
assert.equal(Object.keys(before).length, 13);
for (const [context, original] of Object.entries(before)) {
assert.deepEqual(after[context], original, context);
assert.equal(salesCurrent({ ...draft, context_opportunity: context }, after[context]), true, context);
}
const model = createProjectDossierReport(draft, { selectedSections: new Set(['finance']), version: 'QA G3 — Cessação2026' });
const encoded = JSON.stringify(model);
for (const token of ['opportunity:322', 'Manica', '2026-10', '700.00', '112.00', '100.00', '31/12/2025', temporaryBenefitVersion, 'P045', 'Lei3/2025', 'Lei22/2022']) assert.ok(encoded.includes(token), token);
if (process.env.IVA_G3_CALCULATED) fs.writeFileSync(process.env.IVA_G3_CALCULATED, JSON.stringify(draft));
});