11// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
22// SPDX-License-Identifier: AGPL-3.0-or-later
33
4+ import fs from 'fs' ;
5+ import path from 'path' ;
46import { expect , test } from '@playwright/test' ;
57import { loginToAkaunting } from './support/auth' ;
68
79const REAL_EMIT_FLOW_ENABLED = process . env . NFSE_E2E_REAL_EMIT_FLOW === '1' ;
10+ const KNOWN_INVALID_CUSTOMERS = new Set ( [ 'librecode' ] ) ;
11+ const RETRYABLE_GATEWAY_CODES = / ( E 0 0 8 4 | E 0 2 0 2 | E 0 7 0 0 ) / i;
12+ const EXAMPLE_FEDERAL_PROFILE = {
13+ federalPiscofinsSituacaoTributaria : '1' ,
14+ federalPiscofinsTipoRetencao : '4' ,
15+ federalPiscofinsAliquotaPis : '0.65' ,
16+ federalPiscofinsAliquotaCofins : '3.00' ,
17+ federalValorIrrf : '2.00' ,
18+ federalValorCsll : '1.00' ,
19+ federalValorCp : '0.50' ,
20+ tributosFedP : '3.65' ,
21+ tributosEstP : '0.00' ,
22+ tributosMunP : '2.00' ,
23+ } ;
824
925test . use ( { serviceWorkers : 'block' } ) ;
1026
1127const emitFormsSelector = "form[action*='/nfse/invoices/'][action$='/emit']" ;
1228
29+ function currentLaravelLogPath ( ) : string {
30+ const now = new Date ( ) ;
31+ const year = String ( now . getFullYear ( ) ) ;
32+ const month = String ( now . getMonth ( ) + 1 ) . padStart ( 2 , '0' ) ;
33+ const day = String ( now . getDate ( ) ) . padStart ( 2 , '0' ) ;
34+
35+ return path . resolve ( __dirname , '../../../storage/logs' , `laravel-${ year } -${ month } -${ day } .log` ) ;
36+ }
37+
38+ function normalizeDecimal ( value : string | null | undefined ) : string {
39+ const raw = ( value ?? '' ) . trim ( ) ;
40+
41+ if ( raw === '' ) {
42+ return '' ;
43+ }
44+
45+ const cleaned = raw . replace ( / \s + / g, '' ) . replace ( / R \$ / g, '' ) . replace ( / % / g, '' ) ;
46+
47+ if ( cleaned . includes ( ',' ) && cleaned . includes ( '.' ) ) {
48+ return Number ( cleaned . replace ( / \. / g, '' ) . replace ( ',' , '.' ) ) . toFixed ( 2 ) ;
49+ }
50+
51+ if ( cleaned . includes ( ',' ) ) {
52+ return Number ( cleaned . replace ( ',' , '.' ) ) . toFixed ( 2 ) ;
53+ }
54+
55+ return Number ( cleaned ) . toFixed ( 2 ) ;
56+ }
57+
58+ function calculatePercentageValue ( baseAmount : string , aliquota : string ) : string {
59+ const calculated = Number ( baseAmount ) * Number ( aliquota ) / 100 ;
60+ const cents = Math . round ( ( calculated + Number . EPSILON ) * 100 ) ;
61+
62+ return ( cents / 100 ) . toFixed ( 2 ) ;
63+ }
64+
65+ function isEligiblePendingInvoice ( customerName : string , invoiceAmount : string ) : boolean {
66+ const normalizedCustomer = customerName . trim ( ) . toLowerCase ( ) ;
67+
68+ if ( KNOWN_INVALID_CUSTOMERS . has ( normalizedCustomer ) ) {
69+ return false ;
70+ }
71+
72+ // Ensure invoice amount is enough to generate meaningful retention values
73+ const minAmount = 50.00 ; // Minimum R$50 to test retentions
74+
75+ return Number ( invoiceAmount ) >= minAmount ;
76+ }
77+
78+ function extractLatestEmissionPayload ( logContents : string , invoiceId : string ) : Record < string , unknown > | null {
79+ const lines = logContents . split ( / \r ? \n / ) . reverse ( ) ;
80+
81+ for ( const line of lines ) {
82+ if ( ! line . includes ( 'NFS-e emission payload' ) ) {
83+ continue ;
84+ }
85+
86+ const jsonStart = line . indexOf ( '{' ) ;
87+
88+ if ( jsonStart === - 1 ) {
89+ continue ;
90+ }
91+
92+ try {
93+ const payload = JSON . parse ( line . slice ( jsonStart ) ) as Record < string , unknown > ;
94+
95+ if ( String ( payload . invoice_id ?? '' ) === invoiceId ) {
96+ return payload ;
97+ }
98+ } catch {
99+ // Ignore incomplete lines while the logger is still flushing.
100+ }
101+ }
102+
103+ return null ;
104+ }
105+
106+ async function waitForEmissionPayload ( logPath : string , invoiceId : string ) : Promise < Record < string , unknown > > {
107+ for ( let attempt = 0 ; attempt < 80 ; attempt += 1 ) {
108+ const logContents = fs . existsSync ( logPath )
109+ ? fs . readFileSync ( logPath , 'utf8' )
110+ : '' ;
111+ const payload = extractLatestEmissionPayload ( logContents , invoiceId ) ;
112+
113+ if ( payload !== null ) {
114+ return payload ;
115+ }
116+
117+ await new Promise ( ( resolve ) => setTimeout ( resolve , 250 ) ) ;
118+ }
119+
120+ throw new Error ( `Could not find NFS-e emission payload log for invoice ${ invoiceId } .` ) ;
121+ }
122+
123+ async function applyExampleFederalProfile ( page ) : Promise < void > {
124+ await page . goto ( '/1/nfse/settings?tab=federal' , { waitUntil : 'domcontentloaded' } ) ;
125+ await page . waitForLoadState ( 'networkidle' ) ;
126+
127+ await expect ( page . locator ( '#tab-panel-federal' ) ) . toBeVisible ( ) ;
128+
129+ await page . locator ( '#federal-piscofins-situacao' ) . selectOption ( EXAMPLE_FEDERAL_PROFILE . federalPiscofinsSituacaoTributaria ) ;
130+ await page . locator ( '#federal-piscofins-tipo-retencao' ) . selectOption ( EXAMPLE_FEDERAL_PROFILE . federalPiscofinsTipoRetencao ) ;
131+ await page . locator ( '#federal_piscofins_aliquota_pis' ) . fill ( EXAMPLE_FEDERAL_PROFILE . federalPiscofinsAliquotaPis ) ;
132+ await page . locator ( '#federal_piscofins_aliquota_cofins' ) . fill ( EXAMPLE_FEDERAL_PROFILE . federalPiscofinsAliquotaCofins ) ;
133+ await page . locator ( '#federal_valor_irrf' ) . fill ( EXAMPLE_FEDERAL_PROFILE . federalValorIrrf ) ;
134+ if ( await page . locator ( '#federal-valor-csll-row' ) . isVisible ( ) ) {
135+ await page . locator ( '#federal_valor_csll' ) . fill ( EXAMPLE_FEDERAL_PROFILE . federalValorCsll ) ;
136+ }
137+ await page . locator ( '#federal_valor_cp' ) . fill ( EXAMPLE_FEDERAL_PROFILE . federalValorCp ) ;
138+ await page . locator ( '#tributos_fed_p' ) . fill ( EXAMPLE_FEDERAL_PROFILE . tributosFedP ) ;
139+ await page . locator ( '#tributos_est_p' ) . fill ( EXAMPLE_FEDERAL_PROFILE . tributosEstP ) ;
140+ await page . locator ( '#tributos_mun_p' ) . fill ( EXAMPLE_FEDERAL_PROFILE . tributosMunP ) ;
141+ await page . locator ( '#tributos_fed_sn' ) . fill ( '0.00' ) ;
142+ await page . locator ( '#tributos_est_sn' ) . fill ( '0.00' ) ;
143+ await page . locator ( '#tributos_mun_sn' ) . fill ( '0.00' ) ;
144+
145+ await page . locator ( '#tab-panel-federal button[type="submit"]' ) . click ( ) ;
146+ await page . waitForLoadState ( 'networkidle' ) ;
147+ await expect ( page ) . toHaveURL ( / \/ 1 \/ n f s e \/ s e t t i n g s / ) ;
148+ }
149+
13150test ( 'pending invoices page exposes emission CTA when authenticated' , async ( { page } , testInfo ) => {
14151 await loginToAkaunting ( page , testInfo ) ;
15152
@@ -35,6 +172,9 @@ test('real happy path emits NFS-e from pending list', async ({ page }, testInfo)
35172
36173 await loginToAkaunting ( page , testInfo ) ;
37174
175+ await applyExampleFederalProfile ( page ) ;
176+
177+ const logPath = currentLaravelLogPath ( ) ;
38178 await page . goto ( '/1/nfse/invoices/pending' , { waitUntil : 'domcontentloaded' } ) ;
39179 await page . waitForLoadState ( 'networkidle' ) ;
40180
@@ -45,26 +185,111 @@ test('real happy path emits NFS-e from pending list', async ({ page }, testInfo)
45185 test . skip ( true , 'No pending invoices available to execute real emission happy path.' ) ;
46186 }
47187
48- const firstEmitButton = emitButtons . first ( ) ;
188+ const attemptedInvoices = new Set < string > ( ) ;
189+ let emittedSuccessfully = false ;
190+ let lastGatewayDetail = '' ;
49191
50- await expect ( firstEmitButton ) . toBeVisible ( ) ;
192+ for ( let attempt = 0 ; attempt < emitButtonsCount ; attempt += 1 ) {
193+ const emitForms = page . locator ( emitFormsSelector ) ;
194+ const currentButtons = page . locator ( `${ emitFormsSelector } button[type='submit']` ) ;
195+ const currentCount = await currentButtons . count ( ) ;
51196
52- if ( ! ( await firstEmitButton . isEnabled ( ) ) ) {
53- const readinessItems = await page
54- . locator ( 'li' )
55- . filter ( { hasText : / c o n f i g u r e d | c o n f i g u r a d o | r e a d y | p r o n t / i } )
56- . allInnerTexts ( ) ;
197+ let selectedIndex = - 1 ;
198+ let invoiceId = '' ;
199+ let invoiceAmount = '' ;
57200
58- const details = readinessItems . length > 0 ? readinessItems . join ( '; ' ) : 'unknown configuration prerequisite' ;
201+ for ( let index = 0 ; index < currentCount ; index += 1 ) {
202+ const candidateForm = emitForms . nth ( index ) ;
203+ const emitAction = await candidateForm . getAttribute ( 'action' ) ;
204+ const invoiceIdMatch = emitAction ?. match ( / \/ i n v o i c e s \/ ( \d + ) \/ e m i t $ / ) ;
205+ const candidateInvoiceId = invoiceIdMatch ?. [ 1 ] ?? '' ;
59206
60- throw new Error ( `Real emission blocked by pending settings: ${ details } ` ) ;
61- }
207+ if ( candidateInvoiceId === '' || attemptedInvoices . has ( candidateInvoiceId ) ) {
208+ continue ;
209+ }
62210
63- await firstEmitButton . click ( ) ;
211+ const candidateRow = candidateForm . locator ( 'xpath=ancestor::tr[1]' ) ;
212+ const customerName = ( await candidateRow . locator ( 'td' ) . nth ( 1 ) . innerText ( ) ) . trim ( ) ;
213+ const candidateAmount = normalizeDecimal ( await candidateRow . locator ( 'td' ) . nth ( 2 ) . innerText ( ) ) ;
64214
65- await page . waitForLoadState ( 'networkidle' ) ;
215+ if ( ! isEligiblePendingInvoice ( customerName , candidateAmount ) ) {
216+ continue ;
217+ }
218+
219+ selectedIndex = index ;
220+ invoiceId = candidateInvoiceId ;
221+ invoiceAmount = candidateAmount ;
222+ break ;
223+ }
224+
225+ if ( selectedIndex === - 1 ) {
226+ break ;
227+ }
228+
229+ attemptedInvoices . add ( invoiceId ) ;
66230
67- await expect ( page ) . toHaveURL ( / \/ 1 \/ n f s e \/ i n v o i c e s \/ \d + $ / ) ;
68- await expect ( page . locator ( 'body' ) ) . toContainText ( / ( e m i t i d a c o m s u c e s s o | s u c c e s s f u l l y e m i t t e d ) / i) ;
69- await expect ( page . locator ( 'body' ) ) . toContainText ( / ( d a d o s d a n f s - e | n f s - e d a t a ) / i) ;
231+ const emitButton = currentButtons . nth ( selectedIndex ) ;
232+ await expect ( emitButton ) . toBeVisible ( ) ;
233+
234+ if ( ! ( await emitButton . isEnabled ( ) ) ) {
235+ const readinessItems = await page
236+ . locator ( 'li' )
237+ . filter ( { hasText : / c o n f i g u r e d | c o n f i g u r a d o | r e a d y | p r o n t / i } )
238+ . allInnerTexts ( ) ;
239+
240+ const details = readinessItems . length > 0 ? readinessItems . join ( '; ' ) : 'unknown configuration prerequisite' ;
241+
242+ throw new Error ( `Real emission blocked by pending settings: ${ details } ` ) ;
243+ }
244+
245+ await emitButton . click ( ) ;
246+ await page . waitForLoadState ( 'networkidle' ) ;
247+
248+ const emissionPayload = await waitForEmissionPayload ( logPath , invoiceId ) ;
249+
250+ expect ( String ( emissionPayload . tipoAmbiente ?? '' ) ) . toBe ( '2' ) ;
251+ expect ( String ( emissionPayload . tributacao_federal_mode ?? '' ) ) . toBe ( 'percentage_profile' ) ;
252+ expect ( String ( emissionPayload . federal_piscofins_situacao_tributaria ?? '' ) ) . toBe ( EXAMPLE_FEDERAL_PROFILE . federalPiscofinsSituacaoTributaria ) ;
253+ expect ( String ( emissionPayload . federal_piscofins_tipo_retencao ?? '' ) ) . toBe ( EXAMPLE_FEDERAL_PROFILE . federalPiscofinsTipoRetencao ) ;
254+ expect ( String ( emissionPayload . federal_piscofins_aliquota_pis ?? '' ) ) . toBe ( EXAMPLE_FEDERAL_PROFILE . federalPiscofinsAliquotaPis ) ;
255+ expect ( String ( emissionPayload . federal_piscofins_aliquota_cofins ?? '' ) ) . toBe ( EXAMPLE_FEDERAL_PROFILE . federalPiscofinsAliquotaCofins ) ;
256+ expect ( String ( emissionPayload . federal_piscofins_base_calculo ?? '' ) ) . toBe ( invoiceAmount ) ;
257+ expect ( String ( emissionPayload . federal_piscofins_valor_pis ?? '' ) ) . toBe ( calculatePercentageValue ( invoiceAmount , EXAMPLE_FEDERAL_PROFILE . federalPiscofinsAliquotaPis ) ) ;
258+ expect ( String ( emissionPayload . federal_piscofins_valor_cofins ?? '' ) ) . toBe ( calculatePercentageValue ( invoiceAmount , EXAMPLE_FEDERAL_PROFILE . federalPiscofinsAliquotaCofins ) ) ;
259+ expect ( String ( emissionPayload . federal_valor_irrf ?? '' ) ) . toBe ( calculatePercentageValue ( invoiceAmount , EXAMPLE_FEDERAL_PROFILE . federalValorIrrf ) ) ;
260+ expect ( String ( emissionPayload . federal_valor_csll ?? '' ) ) . toBe ( calculatePercentageValue ( invoiceAmount , EXAMPLE_FEDERAL_PROFILE . federalValorCsll ) ) ;
261+ expect ( String ( emissionPayload . federal_valor_cp ?? '' ) ) . toBe ( '' ) ;
262+ expect ( String ( emissionPayload . indicador_tributacao ?? '' ) ) . toBe ( '2' ) ;
263+ expect ( String ( emissionPayload . tributos_fed_p ?? '' ) ) . toBe ( EXAMPLE_FEDERAL_PROFILE . tributosFedP ) ;
264+ expect ( String ( emissionPayload . tributos_est_p ?? '' ) ) . toBe ( EXAMPLE_FEDERAL_PROFILE . tributosEstP ) ;
265+ expect ( String ( emissionPayload . tributos_mun_p ?? '' ) ) . toBe ( EXAMPLE_FEDERAL_PROFILE . tributosMunP ) ;
266+
267+ if ( / \/ 1 \/ n f s e \/ i n v o i c e s \/ p e n d i n g $ / . test ( page . url ( ) ) ) {
268+ const bodyText = await page . locator ( 'body' ) . innerText ( ) ;
269+ const errorDetail = bodyText . match ( / D e t a l h e S E F I N : [ \s \S ] * / i) ?. [ 0 ] ?? 'unknown gateway detail' ;
270+ lastGatewayDetail = errorDetail ;
271+
272+ if ( RETRYABLE_GATEWAY_CODES . test ( errorDetail ) ) {
273+ await page . goto ( '/1/nfse/invoices/pending' , { waitUntil : 'domcontentloaded' } ) ;
274+ await page . waitForLoadState ( 'networkidle' ) ;
275+ continue ;
276+ }
277+
278+ throw new Error ( `Real emission remained on pending list after using expected federal payload: ${ errorDetail } ` ) ;
279+ }
280+
281+ await expect ( page ) . toHaveURL ( / \/ 1 \/ n f s e \/ i n v o i c e s \/ \d + $ / ) ;
282+ await expect ( page . locator ( 'body' ) ) . toContainText ( / ( e m i t i d a c o m s u c e s s o | e m i t t e d s u c c e s s f u l l y ) / i) ;
283+ await expect ( page . locator ( 'body' ) ) . toContainText ( / ( d a d o s d a n f s - e | n f s - e d a t a ) / i) ;
284+ emittedSuccessfully = true ;
285+ break ;
286+ }
287+
288+ if ( ! emittedSuccessfully ) {
289+ if ( lastGatewayDetail === '' || RETRYABLE_GATEWAY_CODES . test ( lastGatewayDetail ) ) {
290+ test . skip ( true , `Emission blocked by external SEFIN business constraint: ${ lastGatewayDetail || 'no detail returned' } ` ) ;
291+ }
292+
293+ throw new Error ( `Could not emit any eligible pending invoice. Last gateway detail: ${ lastGatewayDetail || 'none' } ` ) ;
294+ }
70295} ) ;
0 commit comments