feat(irpc): reconcile CBF16 cessation and documented prior quotas in QA

This commit is contained in:
2026-10-26 15:16:45 +02:00
parent ba83afe7d8
commit 992c97ad4e
7 changed files with 143 additions and 15 deletions
@@ -0,0 +1,54 @@
<?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 IrpcSpecialCessationPriorDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_cbf16_cessation_and_prior_quotas_recover_without_consuming_declared_balances(): 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()]));$id=null;$etag=null;
foreach(['cessation','prior','annual','exit','missing','negative','absent']as$case){
$d=json_decode(file_get_contents(base_path('outputs/irpc-l7-cessation-prior/'.$case.'.json')),true,flags:JSON_THROW_ON_ERROR);
$personal=['person_type'=>$d['person_type']];unset($d['person_type']);
if($id===null)$id=$this->postJson('/api/v1/simulation-drafts',['personal_snapshot'=>$personal,'economic_snapshot'=>$d],['Idempotency-Key'=>(string)Str::uuid()])->assertCreated()->json('data.id');
else $this->putJson('/api/v1/simulation-drafts/'.$id,['personal_snapshot'=>$personal,'economic_snapshot'=>$d],['If-Match'=>$etag])->assertOk();
$r=$this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk();$etag=$r->headers->get('ETag');$back=$r->json('data.economic_snapshot');
$this->assertSame($d['modelo_a_snapshot'],$back['modelo_a_snapshot']);$this->assertSame($d['business_plan_snapshot'],$back['business_plan_snapshot']);
$s=json_decode($back['modelo_a_snapshot'],true,flags:JSON_THROW_ON_ERROR);$b=$s['irpcNormal'][$d['context_opportunity']];$m=$b['result']['model'];$a=$m['special']['acceleration'];
$this->assertSame('IRPC-normal-adjustments/13',$m['version']);$this->assertSame($b['special']['acceleration']['monthly'],$a['monthly']['input']);
$this->assertContains('IRPC-normal-adjustments/12',array_column(array_column($b['history'],'model'),'version'));
$this->assertContains('IRPC-normal-adjustments/11',array_column(array_column($b['history'],'model'),'version'));
$this->assertSame($b['depreciation'],$a['normal']['input']);$this->assertSame(['scope'=>'L8','payable'=>null,'recoverable'=>null],$m['special']['finalBalance']);
$this->assertNull($m['tax']);$this->assertArrayHasKey('irpcWithholdings',$s);
if($case==='cessation'){
$v=$a['monthly']['assets'][0];$this->assertSame(6,$v['months']);$this->assertSame('absent',$v['exit']['state']);$this->assertSame('10',$a['additionalDeduction']['numerator']);
$this->assertSame('3120',$m['special']['taxableProfit']['numerator']);$this->assertSame(['numerator'=>'4992','denominator'=>'5'],$m['special']['colecta']);
}elseif($case==='prior'||$case==='annual'){
$v=$a['monthly']['assets'][0];$this->assertSame('150',$b['depreciation']['assets'][0]['previousAccounting']);$this->assertSame('100',$b['depreciation']['assets'][0]['previousFiscal']);$this->assertSame('50',$b['depreciation']['assets'][0]['priorAvailable']);
$this->assertSame($case==='prior'?'15':'30',$v['prior']['acceleratedUsed']['numerator']);$this->assertSame($case==='prior'?'35':'20',$v['prior']['acceleratedRemaining']['numerator']);
$this->assertSame($case==='prior'?'25':'50',$a['additionalDeduction']['numerator']);$this->assertSame($case==='prior'?'3105':'6210',$m['special']['taxableProfit']['numerator']);
$this->assertSame(['numerator'=>$case==='prior'?'4968':'9936','denominator'=>'5'],$m['special']['colecta']);$this->assertSame($b['depreciation'],$a['accelerated']['input']);
}elseif($case==='exit'){
$v=$a['monthly']['assets'][0];$this->assertSame(5,$v['months']);$this->assertSame('2026-06-20',$v['exit']['date']);
$this->assertSame(['numerator'=>'125','denominator'=>'3'],$v['normalQuota']);$this->assertSame(['numerator'=>'125','denominator'=>'6'],$a['additionalDeduction']);
}elseif($case==='missing'){
$this->assertSame('indeterminate',$a['status']);$this->assertNull($a['additionalDeduction']);$this->assertSame('3130',$m['taxableProfit']['numerator']);$this->assertNull($m['special']['colecta']);
}elseif($case==='negative'){
$this->assertSame('not_applicable',$a['rows'][0]['status']);$this->assertFalse($a['monthly']['assets'][0]['applied']);$this->assertSame('0',$a['additionalDeduction']['numerator']);
}else{
$this->assertSame('absent',$a['status']);$this->assertSame([],$a['monthly']['assets']);$this->assertSame('0',$a['additionalDeduction']['numerator']);
}
file_put_contents(base_path('outputs/irpc-l7-cessation-prior/recovered-'.$case.'.json'),json_encode($back,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE));
}
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {normalBook,normalCurrent,calculateNormal,recordNormal,saveNormalBook,normalHtml} from '../../resources/js/lib/irpc-normal.ts';
import {validateSpecial} from '../../resources/js/lib/irpc-special.ts';
import {fraction} from '../../resources/js/lib/fiscal-rational.ts';
import {createProjectDossierReport} from '../../resources/js/lib/project-dossier-report.ts';
const read=name=>JSON.parse(fs.readFileSync(new URL('./fixtures/'+name,import.meta.url),'utf8'));
const val=v=>v?Number(v.numerator)/Number(v.denominator):null;
function accepted(){const d=read('irpc-l7-preserved.json').acceleration,f=read('irpc-l7-start-preserved.json');d.business_plan_snapshot=f.plan;d.modelo_a_snapshot=saveNormalBook(d,f.book);return{d,b:normalBook(d)};}
function setup(period='cessation',prior=true){
const{d,b}=accepted(),months=period==='annual'?12:6,p=JSON.parse(d.business_plan_snapshot);p.plan.assetDepreciation='120';d.business_plan_snapshot=JSON.stringify(p);
if(period==='annual')delete b.periodsFx;else Object.assign(b.periodsFx,{kind:'cessation',from:'2026-01-01',to:'2026-06-30',reference:'QA cessação documentada30Junho2026;meses completos sem transmissão presumida'});
b.schedules.interest.amount=String(months*20);
Object.assign(b.depreciation.assets[0],{start:prior?'2025-01-01':'2026-01-15',timing:'months',accounting:String(months*10),previousAccounting:prior?'150':'0',previousFiscal:prior?'100':'0',priorAvailable:prior?'50':'0',previousReference:prior?'QA quotas contabilísticas150,fiscais100,parcela50 temporariamente tributada;sem encargos permanentemente excluídos':'QA ausência declarada de quotas anteriores;activo entrou em2026',exitState:'absent',exitDate:''});
for(const part of[b.special.expenses,b.special.cooperative,b.special.acceleration])Object.assign(part.qualification,{from:'2026-01-01',to:period==='annual'?'2026-12-31':'2026-06-30',periodReference:period==='annual'?'QA ano civil2026':b.periodsFx.reference});
delete b.special.acceleration.monthly.startPeriod;
b.special.acceleration.monthly.continuation={version:1,period,reference:'QA CBF16 vigente no sujeito/projecto e período2026 declarado;sem cumulação',historyReference:'QA activo novo2025/2026,método constante e duodécimos desde entrada;quotas anteriores reconciliadas no mapaL6,não recalculadas',monthlyFromStart:'yes'};return{d,b};
}
test('accepted12 result,key and fullhistory remain exact without continuation',()=>{const{d,b}=accepted();assert.equal(normalCurrent(d,b),true);assert.deepEqual(calculateNormal(d,b),b.result.model);assert.deepEqual(normalBook(d).history,b.history);});
test('cessation reuses6 months without assuming assetexit;50 normal,75 accelerated capped60,additional10',()=>{const{d,b}=setup('cessation',false),asset=JSON.stringify(b.depreciation),period=JSON.stringify(b.periodsFx),m=calculateNormal(d,b),s=m.special,a=s.acceleration.monthly.assets[0];assert.equal(m.version,'IRPC-normal-adjustments/13');assert.equal(a.months,6);assert.equal(a.exit.state,'absent');assert.equal(val(a.normalQuota),50);assert.equal(val(a.acceleratedQuota),75);assert.equal(val(a.acceleratedDeduction),60);assert.equal(val(a.additionalDeduction),10);assert.equal(val(m.taxableProfit),3130);assert.equal(val(m.colecta),1001.6);assert.equal(val(s.taxableProfit),3120);assert.equal(val(s.taxableBase),3120);assert.equal(val(s.colecta),998.4);assert.equal(JSON.stringify(b.depreciation),asset);assert.equal(JSON.stringify(b.periodsFx),period);assert.equal(s.finalBalance.payable,null);});
test('cessation with priorbook150,fiscal100,available50 recovers15 once and adds only25 to normaldeduction',()=>{const{d,b}=setup(),m=calculateNormal(d,b),s=m.special,a=s.acceleration.monthly.assets[0];assert.equal(a.months,6);assert.equal(val(a.normalDeduction),50);assert.equal(val(a.acceleratedDeduction),75);assert.equal(val(a.prior.normalUsed),0);assert.equal(val(a.prior.acceleratedUsed),15);assert.equal(val(a.prior.normalRemaining),50);assert.equal(val(a.prior.acceleratedRemaining),35);assert.equal(val(a.prior.normalFiscalRemaining),850);assert.equal(val(a.prior.acceleratedFiscalRemaining),825);assert.equal(val(s.acceleration.additionalDeduction),25);assert.equal(val(s.taxableProfit),3105);assert.equal(val(s.taxableBase),3105);assert.equal(val(s.colecta),993.6);assert.equal(b.depreciation.assets[0].priorAvailable,'50');assert.equal(b.depreciation.assets[0].previousFiscal,'100');});
test('annual2026 continuation preserves2025 actualbalances and records30priorused within150 deduction,additional50',()=>{const{d,b}=setup('annual'),m=calculateNormal(d,b),s=m.special,a=s.acceleration.monthly.assets[0];assert.equal(a.months,12);assert.equal(val(a.normalQuota),100);assert.equal(val(a.acceleratedQuota),150);assert.equal(val(a.prior.acceleratedUsed),30);assert.equal(val(a.prior.acceleratedRemaining),20);assert.equal(val(s.acceleration.additionalDeduction),50);assert.equal(val(m.taxableProfit),6260);assert.equal(val(s.taxableProfit),6210);assert.equal(val(s.colecta),1987.2);assert.deepEqual(s.acceleration.normal.input,s.acceleration.accelerated.input);});
test('documented assetexit excludes eventmonth and keeps exact fractional quotas;cessation date does not edit exit',()=>{const{d,b}=setup();Object.assign(b.depreciation.assets[0],{exitState:'known',exitDate:'2026-06-20'});const a=calculateNormal(d,b).special.acceleration.monthly.assets[0];assert.equal(a.months,5);assert.deepEqual(a.normalQuota,fraction(125n,3n));assert.deepEqual(a.acceleratedQuota,fraction(125n,2n));assert.deepEqual(a.additionalDeduction,fraction(125n,6n));assert.deepEqual(a.prior.acceleratedUsed,fraction(5n,2n));assert.deepEqual(a.prior.acceleratedRemaining,fraction(95n,2n));});
test('remaining fiscalbalance and accounted/priorcaps bind both calculations;available amount is never a second deduction',()=>{const{d,b}=setup();Object.assign(b.depreciation.assets[0],{previousAccounting:'1000',previousFiscal:'980',priorAvailable:'20'});let a=calculateNormal(d,b).special.acceleration.monthly.assets[0];assert.equal(val(a.normalDeduction),20);assert.equal(val(a.acceleratedDeduction),20);assert.equal(val(a.additionalDeduction),0);assert.equal(val(a.prior.acceleratedFiscalRemaining),0);const second=setup(),p=JSON.parse(second.d.business_plan_snapshot);p.plan.assetDepreciation='0';second.d.business_plan_snapshot=JSON.stringify(p);second.b.depreciation.assets[0].accounting='0';a=calculateNormal(second.d,second.b).special.acceleration.monthly.assets[0];assert.equal(val(a.normalDeduction),50);assert.equal(val(a.acceleratedDeduction),50);assert.equal(val(a.additionalDeduction),0);assert.equal(val(a.prior.acceleratedRemaining),0);});
test('missing support or missing priorbalance blocks only dependent CBF16;absence and negative remain explicit',()=>{for(const key of['reference','historyReference','monthlyFromStart']){const{d,b}=setup();b.special.acceleration.monthly.continuation[key]='';const m=calculateNormal(d,b);assert.equal(m.special.acceleration.additionalDeduction,null);assert.notEqual(m.colecta,null);}const missing=setup();missing.b.depreciation.assets[0].previousFiscal='';const pending=calculateNormal(missing.d,missing.b);assert.equal(pending.special.acceleration.additionalDeduction,null);assert.match(pending.depreciation.pending.join(' '),/em falta/);assert.equal(missing.b.depreciation.assets[0].previousFiscal,'');for(const state of['negative','absent']){const{d,b}=setup();if(state==='negative')b.special.acceleration.rows[0].eligible='no';else{b.special.acceleration.rows=[];Object.assign(b.special.acceleration.qualification,{state:'absent',eligible:''});}const s=calculateNormal(d,b).special;assert.equal(val(s.acceleration.additionalDeduction),0);assert.equal(s.acceleration.status,state==='absent'?'absent':'calculated');if(state==='negative')assert.equal(s.acceleration.rows[0].status,'not_applicable');}});
test('otherstartyears,nonmonthlyhistory,transition,cumulation and prioramounts for currentyearentry stay out of scope',()=>{for(const mutate of[b=>b.depreciation.assets[0].start='2024-01-01',b=>b.special.acceleration.monthly.continuation.monthlyFromStart='no',b=>b.periodsFx.kind='transition',b=>b.special.acceleration.noOtherBenefits='no',b=>b.depreciation.assets[0].start='2026-01-01']){const{d,b}=setup();mutate(b);const m=calculateNormal(d,b);assert.equal(m.special.acceleration.additionalDeduction,null);assert.notEqual(m.colecta,null);}});
test('save-recover13 preserves12result and allhistorical balances;recalc idempotent and reports reconciliation without officialconsumption',()=>{const{d,b}=setup(),history=structuredClone(b.history),old=structuredClone(b.result),asset=JSON.stringify(b.depreciation),plan=d.business_plan_snapshot,others=JSON.parse(d.modelo_a_snapshot).irpcWithholdings;d.modelo_a_snapshot=saveNormalBook(d,recordNormal(d,b));let back=normalBook(JSON.parse(JSON.stringify(d)));assert.equal(normalCurrent(d,back),true);assert.deepEqual(back.history,[...history,old]);assert.equal(old.model.version,'IRPC-normal-adjustments/12');assert.equal(JSON.stringify(back.depreciation),asset);assert.equal(d.business_plan_snapshot,plan);assert.deepEqual(JSON.parse(d.modelo_a_snapshot).irpcWithholdings,others);const html=normalHtml(d);assert.match(html,/2026-01-01–2026-06-30/);assert.match(html,/recuperada CBF16:15.00/);assert.match(html,/saldo anterior aritmético CBF16:35.00/);assert.match(html,/sem consumo de saldos oficiais/);assert.match(html,/colecta sobre a base:993.60/);assert.match(html,/Saldo final a pagar\/recuperar:Indeterminado/);assert.match(JSON.stringify(createProjectDossierReport(d,{selectedSections:new Set(['finance'])})),/quotas contabilísticas anteriores/);d.modelo_a_snapshot=saveNormalBook(d,recordNormal(d,back));back=normalBook(d);assert.equal(val(back.result.model.special.acceleration.additionalDeduction),25);assert.equal(back.depreciation.assets[0].priorAvailable,'50');back.depreciation.assets[0].previousFiscal='110';assert.equal(normalCurrent(d,back),false);});
test('invalid or overlapping optionalpreparation cannot replace preservedbooks',()=>{for(const c of[null,{version:2,period:'annual',reference:'QA',historyReference:'QA',monthlyFromStart:'yes'},{version:1,period:'other',reference:'QA',historyReference:'QA',monthlyFromStart:'yes'}]){const{b}=setup();b.special.acceleration.monthly.continuation=c;assert.throws(()=>validateSpecial(b.special),/não recuperável/);}const{b}=setup();b.special.acceleration.monthly.startPeriod={version:1,reference:'QA'};assert.throws(()=>validateSpecial(b.special),/incompatível/);});
test('prepare only compatible new guardedAPI cases with missing,negative and absence variants',()=>{const cases={cessation:setup('cessation',false),prior:setup(),annual:setup('annual'),exit:setup(),missing:setup(),negative:setup(),absent:setup()};Object.assign(cases.exit.b.depreciation.assets[0],{exitState:'known',exitDate:'2026-06-20'});cases.missing.b.special.acceleration.monthly.continuation.historyReference='';cases.negative.b.special.acceleration.rows[0].eligible='no';cases.absent.b.special.acceleration.rows=[];Object.assign(cases.absent.b.special.acceleration.qualification,{state:'absent',eligible:''});fs.mkdirSync('outputs/irpc-l7-cessation-prior',{recursive:true});for(const[name,{d,b}]of Object.entries(cases)){d.modelo_a_snapshot=saveNormalBook(d,recordNormal(d,b,'2026-10-26T18:00:00+02:00'));fs.writeFileSync('outputs/irpc-l7-cessation-prior/'+name+'.json',JSON.stringify(d));fs.writeFileSync('outputs/irpc-l7-cessation-prior/'+name+'.html',normalHtml(d));}});