@fayz-ai/plugin-fiscal-br 0.10.3 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/connectors/plugnotas.d.ts +6 -30
- package/dist/connectors/plugnotas.d.ts.map +1 -1
- package/dist/data/issued.d.ts +2 -2
- package/dist/data/issued.d.ts.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1143 -314
- package/dist/index.js.map +1 -1
- package/dist/lib/emitters.d.ts +129 -0
- package/dist/lib/emitters.d.ts.map +1 -0
- package/dist/lib/notes.d.ts +42 -0
- package/dist/lib/notes.d.ts.map +1 -0
- package/dist/lib/service.d.ts +20 -0
- package/dist/lib/service.d.ts.map +1 -0
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/views/EmittersPanel.d.ts +10 -0
- package/dist/views/EmittersPanel.d.ts.map +1 -0
- package/dist/views/OrderNoteCard.d.ts +5 -0
- package/dist/views/OrderNoteCard.d.ts.map +1 -0
- package/dist/views/UnitFiscalWidgets.d.ts.map +1 -1
- package/package.json +3 -3
- package/functions/fiscalbr-certificate/index.ts +0 -162
- package/functions/fiscalbr-plugnotas/index.ts +0 -222
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
// fiscalbr-plugnotas — consult, cancel and fetch the PDF of an issued note.
|
|
2
|
-
//
|
|
3
|
-
// Env: PLUGNOTAS_API_KEY (server-only), PLUGNOTAS_BASE_URL (optional; the
|
|
4
|
-
// sandbox host for tests), SUPABASE_URL, SUPABASE_ANON_KEY.
|
|
5
|
-
//
|
|
6
|
-
// Port of the V1 `fiscal-plugnotas-issue` (cancel / poll_status / get_pdf).
|
|
7
|
-
// Unlike the V1, nothing here runs as service role: every read and write goes
|
|
8
|
-
// through `fiscalbr_note_for_provider` / `fiscalbr_note_apply` with the
|
|
9
|
-
// caller's JWT, which check financial.read / financial.edit on the note's unit.
|
|
10
|
-
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.45.0'
|
|
11
|
-
|
|
12
|
-
const corsHeaders = {
|
|
13
|
-
'Access-Control-Allow-Origin': '*',
|
|
14
|
-
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const json = (body: unknown, status = 200) =>
|
|
18
|
-
new Response(JSON.stringify(body), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' } })
|
|
19
|
-
|
|
20
|
-
// deno-lint-ignore no-explicit-any
|
|
21
|
-
type Any = any
|
|
22
|
-
|
|
23
|
-
const API = () => (Deno.env.get('PLUGNOTAS_BASE_URL') || 'https://api.plugnotas.com.br').replace(/\/$/, '')
|
|
24
|
-
|
|
25
|
-
/** PlugNotas answers 502/504 under load; retry those, never a 4xx. */
|
|
26
|
-
async function pnFetch(path: string, init: RequestInit, apiKey: string): Promise<Response> {
|
|
27
|
-
const headers = {
|
|
28
|
-
'x-api-key': apiKey,
|
|
29
|
-
Accept: 'application/json',
|
|
30
|
-
...(init.body ? { 'Content-Type': 'application/json' } : {}),
|
|
31
|
-
...(init.headers || {}),
|
|
32
|
-
} as Record<string, string>
|
|
33
|
-
let res: Response | null = null
|
|
34
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
35
|
-
res = await fetch(`${API()}${path}`, { ...init, headers })
|
|
36
|
-
if (res.status < 500) return res
|
|
37
|
-
if (attempt < 2) await new Promise((r) => setTimeout(r, 1500 * (attempt + 1)))
|
|
38
|
-
}
|
|
39
|
-
return res!
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function readBody(res: Response): Promise<Any> {
|
|
43
|
-
const text = await res.text().catch(() => '')
|
|
44
|
-
try { return JSON.parse(text) } catch { return text || null }
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const REMOTE_STATUS: Record<string, string> = {
|
|
48
|
-
CONCLUIDO: 'authorized', AUTORIZADO: 'authorized', REJEITADO: 'rejected', DENEGADO: 'rejected',
|
|
49
|
-
CANCELADO: 'cancelled', SUBSTITUIDO: 'substituted', PROCESSANDO: 'processing', PENDENTE: 'processing',
|
|
50
|
-
AGENDADO: 'processing', AGUARDANDO_CANCELAMENTO: 'processing',
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function remoteStatus(body: Any): string {
|
|
54
|
-
const first = Array.isArray(body) ? body[0] : body
|
|
55
|
-
return String(first?.situacao || first?.status || '').toUpperCase()
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** PlugNotas sends authorization dates as dd/MM/yyyy as well as ISO. */
|
|
59
|
-
function parseDate(v: unknown): string | null {
|
|
60
|
-
if (!v) return null
|
|
61
|
-
const s = String(v)
|
|
62
|
-
const br = s.match(/^(\d{2})\/(\d{2})\/(\d{4})/)
|
|
63
|
-
const d = br ? new Date(`${br[3]}-${br[2]}-${br[1]}T00:00:00-03:00`) : new Date(s)
|
|
64
|
-
return Number.isNaN(d.getTime()) ? null : d.toISOString()
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function consultPath(note: Any): string {
|
|
68
|
-
return note.model === 'nfse'
|
|
69
|
-
? `/nfse/consultar/${note.provider_document_id}`
|
|
70
|
-
: `/${note.model}/${note.provider_document_id}/resumo`
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** One consult, turned into the patch `fiscalbr_note_apply` accepts. */
|
|
74
|
-
async function consult(note: Any, apiKey: string): Promise<{ patch: Any; remote: string; body: Any }> {
|
|
75
|
-
const res = await pnFetch(consultPath(note), { method: 'GET' }, apiKey)
|
|
76
|
-
const body = await readBody(res)
|
|
77
|
-
const remote = res.ok ? remoteStatus(body) : ''
|
|
78
|
-
const first = Array.isArray(body) ? body[0] : body
|
|
79
|
-
const patch: Any = { status_payload: body }
|
|
80
|
-
let status = REMOTE_STATUS[remote]
|
|
81
|
-
|
|
82
|
-
// A requested cancellation keeps answering CONCLUIDO until the city hall
|
|
83
|
-
// finishes; showing "Autorizada" then would hide the cancellation in flight.
|
|
84
|
-
if (note.cancelled_at && status === 'authorized') status = 'processing'
|
|
85
|
-
// The cancellation itself was refused: the note is valid again.
|
|
86
|
-
if (note.cancelled_at && remote && (remote === 'REJEITADO' || remote === 'DENEGADO')) {
|
|
87
|
-
status = 'authorized'
|
|
88
|
-
patch.cancelled_at = null
|
|
89
|
-
patch.cancel_reason = null
|
|
90
|
-
} else if (remote === 'REJEITADO' || remote === 'DENEGADO') {
|
|
91
|
-
patch.rejection_payload = body
|
|
92
|
-
}
|
|
93
|
-
if (status) patch.status = status
|
|
94
|
-
|
|
95
|
-
const number = first?.numero || first?.numeroNfse || first?.nfse?.numero
|
|
96
|
-
const series = first?.serie ?? first?.nfse?.serie
|
|
97
|
-
const key = first?.chave || first?.chaveAcesso || first?.protocolo?.chave
|
|
98
|
-
const code = first?.codigoVerificacao || first?.nfse?.codigoVerificacao
|
|
99
|
-
if (number) patch.number = String(number)
|
|
100
|
-
if (series != null) patch.series = String(series)
|
|
101
|
-
if (key) patch.access_key = String(key)
|
|
102
|
-
if (code) patch.verification_code = String(code)
|
|
103
|
-
if (status === 'authorized' && !note.cancelled_at) {
|
|
104
|
-
const at = parseDate(first?.dataAutorizacao || first?.emissao || first?.dataEmissao)
|
|
105
|
-
if (at) patch.authorized_at = at
|
|
106
|
-
}
|
|
107
|
-
return { patch, remote, body }
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function isPdf(b: Uint8Array) {
|
|
111
|
-
return b.length > 4 && b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
Deno.serve(async (req) => {
|
|
115
|
-
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
|
116
|
-
try {
|
|
117
|
-
const apiKey = Deno.env.get('PLUGNOTAS_API_KEY')
|
|
118
|
-
if (!apiKey) return json({ error: 'O emissor fiscal não está configurado neste ambiente.' }, 503)
|
|
119
|
-
const auth = req.headers.get('Authorization') || ''
|
|
120
|
-
if (!auth) return json({ error: 'Unauthorized' }, 401)
|
|
121
|
-
const body: Any = await req.json().catch(() => ({}))
|
|
122
|
-
// A user with two accounts has no tenant without this; it only chooses among
|
|
123
|
-
// the caller's own memberships (app.requested_tenant), never widens.
|
|
124
|
-
const tenantId = typeof body?.tenantId === 'string' ? body.tenantId : ''
|
|
125
|
-
const db = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!, {
|
|
126
|
-
global: { headers: { Authorization: auth, ...(tenantId ? { 'x-fayz-tenant': tenantId } : {}) } },
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
const action = String(body?.action || '')
|
|
130
|
-
const documentId = String(body?.document_id || '')
|
|
131
|
-
if (!['poll_status', 'cancel', 'get_pdf'].includes(action) || !documentId) {
|
|
132
|
-
return json({ error: 'Pedido inválido.' }, 400)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const gate = action === 'cancel' ? 'cancel' : 'poll'
|
|
136
|
-
const { data: note, error: noteError } = await db.rpc('fiscalbr_note_for_provider', {
|
|
137
|
-
p_document_id: documentId, p_action: gate,
|
|
138
|
-
})
|
|
139
|
-
if (noteError) {
|
|
140
|
-
const denied = noteError.code === '42501'
|
|
141
|
-
return json({ error: denied ? 'Sem permissão para esta nota.' : noteError.message }, denied ? 403 : 404)
|
|
142
|
-
}
|
|
143
|
-
if (!note?.provider_document_id) return json({ error: 'Esta nota não tem registro no emissor fiscal.' }, 422)
|
|
144
|
-
|
|
145
|
-
const apply = async (patch: Any, event: Any | null) => {
|
|
146
|
-
const { error } = await db.rpc('fiscalbr_note_apply', {
|
|
147
|
-
p_document_id: documentId, p_action: action === 'cancel' ? 'cancel' : 'poll', p_patch: patch, p_event: event,
|
|
148
|
-
})
|
|
149
|
-
if (error) throw new Error(error.message)
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (action === 'poll_status') {
|
|
153
|
-
const { patch, remote } = await consult(note, apiKey)
|
|
154
|
-
await apply(patch, { event_type: 'consulta', status: remote || null })
|
|
155
|
-
return json({ ok: true, status: patch.status ?? note.status })
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
if (action === 'get_pdf') {
|
|
159
|
-
if (!['authorized', 'cancelled'].includes(note.status)) {
|
|
160
|
-
return json({ error: 'O PDF só existe para nota autorizada.' }, 422)
|
|
161
|
-
}
|
|
162
|
-
const path = note.model === 'nfse'
|
|
163
|
-
? `/nfse/pdf/${note.provider_document_id}`
|
|
164
|
-
: `/${note.model}/${note.provider_document_id}/pdf`
|
|
165
|
-
const res = await pnFetch(path, { method: 'GET', headers: { Accept: 'application/pdf,application/json' } }, apiKey)
|
|
166
|
-
let bytes = new Uint8Array(await res.arrayBuffer())
|
|
167
|
-
// Some city halls hand back a link instead of the bytes.
|
|
168
|
-
if (res.ok && !isPdf(bytes)) {
|
|
169
|
-
try {
|
|
170
|
-
const j = JSON.parse(new TextDecoder().decode(bytes))
|
|
171
|
-
const link = [j?.url, j?.link, j?.pdf, j?.data?.url, j?.data?.link, j?.data?.pdf]
|
|
172
|
-
.find((v: unknown) => typeof v === 'string' && /^https?:\/\//.test(v))
|
|
173
|
-
if (link) {
|
|
174
|
-
const r2 = await fetch(link)
|
|
175
|
-
if (r2.ok) bytes = new Uint8Array(await r2.arrayBuffer())
|
|
176
|
-
}
|
|
177
|
-
} catch { /* falls through to the error below */ }
|
|
178
|
-
}
|
|
179
|
-
if (!isPdf(bytes)) return json({ error: 'O emissor ainda não devolveu o PDF desta nota.' }, 502)
|
|
180
|
-
// octet-stream: supabase-js hands that back as a Blob; application/pdf would arrive as text.
|
|
181
|
-
return new Response(bytes, { headers: { ...corsHeaders, 'Content-Type': 'application/octet-stream' } })
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// cancel
|
|
185
|
-
if (note.status !== 'authorized' || note.cancelled_at) {
|
|
186
|
-
return json({ error: 'Só é possível cancelar uma nota autorizada.' }, 422)
|
|
187
|
-
}
|
|
188
|
-
const reason = String(body?.justification || '').trim()
|
|
189
|
-
const min = note.model === 'nfce' ? 15 : 10
|
|
190
|
-
if (reason.length < min) return json({ error: `A justificativa precisa de pelo menos ${min} caracteres.` }, 422)
|
|
191
|
-
if (reason.length > 255) return json({ error: 'A justificativa pode ter no máximo 255 caracteres.' }, 422)
|
|
192
|
-
|
|
193
|
-
const isNfse = note.model === 'nfse'
|
|
194
|
-
const res = await pnFetch(
|
|
195
|
-
isNfse ? `/nfse/cancelar/${note.provider_document_id}` : `/${note.model}/${note.provider_document_id}/cancelamento`,
|
|
196
|
-
{ method: 'POST', body: JSON.stringify(isNfse ? { codigo: String(body?.code || '2'), motivo: reason } : { justificativa: reason }) },
|
|
197
|
-
apiKey,
|
|
198
|
-
)
|
|
199
|
-
const answer = await readBody(res)
|
|
200
|
-
if (!res.ok) {
|
|
201
|
-
const message = String(answer?.error?.message || answer?.message || `HTTP ${res.status}`)
|
|
202
|
-
await apply({}, { event_type: 'cancelamento', status: 'ERRO', justification: reason, response_payload: answer })
|
|
203
|
-
return json({
|
|
204
|
-
error: /prazo|30 min|tempo/i.test(message)
|
|
205
|
-
? `Cancelamento fora do prazo permitido pela SEFAZ/prefeitura: ${message}`
|
|
206
|
-
: message,
|
|
207
|
-
}, 422)
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Accepted, not done: the city hall answers "em processamento" and concludes later.
|
|
211
|
-
const now = new Date().toISOString()
|
|
212
|
-
await apply(
|
|
213
|
-
{ status: 'processing', cancelled_at: now, cancel_reason: reason },
|
|
214
|
-
{ event_type: 'cancelamento', status: 'SOLICITADO', justification: reason, response_payload: answer },
|
|
215
|
-
)
|
|
216
|
-
const { patch, remote } = await consult({ ...note, cancelled_at: now }, apiKey)
|
|
217
|
-
await apply(patch, { event_type: 'consulta', status: remote || null })
|
|
218
|
-
return json({ ok: true, status: patch.status ?? 'processing' })
|
|
219
|
-
} catch (e) {
|
|
220
|
-
return json({ error: e instanceof Error ? e.message : String(e) }, 500)
|
|
221
|
-
}
|
|
222
|
-
})
|