@fayz-ai/plugin-fiscal-br 0.2.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/index.js ADDED
@@ -0,0 +1,1136 @@
1
+ import { connectionSettings, connectionStatus, definePlugin, createConnectionStore, settingsStatus } from '@fayz-ai/core';
2
+
3
+ // src/index.ts
4
+ var PLUGNOTAS_CONNECTOR_ID = "plugnotas";
5
+ var PLUGNOTAS_MARK = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%230F766E'/%3E%3Cpath d='M10 7h12v18l-2-1.5-2 1.5-2-1.5-2 1.5-2-1.5-2 1.5V7Z' fill='white'/%3E%3Cpath d='M13 12h6M13 16h6M13 20h4' stroke='%230F766E' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E";
6
+ var PLUGNOTAS_FIELDS = [
7
+ {
8
+ key: "emitterCnpj",
9
+ label: "CNPJ do emitente",
10
+ type: "text",
11
+ placeholder: "00.000.000/0000-00"
12
+ }
13
+ ];
14
+ var PLUGNOTAS_SETTINGS = [{
15
+ key: "environment",
16
+ label: "Ambiente fiscal",
17
+ description: "Sandbox simula o emissor. Homologa\xE7\xE3o envia para a SEFAZ sem valor fiscal.",
18
+ type: "select",
19
+ options: [
20
+ { value: "sandbox", label: "Sandbox simulado" },
21
+ { value: "homologation", label: "Homologa\xE7\xE3o SEFAZ" }
22
+ ],
23
+ requiredToOperate: true,
24
+ defaultValue: "sandbox"
25
+ }];
26
+ var INTERNAL_SETTINGS = [
27
+ "certificateReady",
28
+ "cscReady",
29
+ "providerEmitterId",
30
+ "certificateExpiresAt",
31
+ "certificateFingerprint",
32
+ // Non-secret operational data written by the host's server-side setup flow.
33
+ // Declaring these keys keeps the shared connection store authoritative while
34
+ // credentials (API key, A1 bytes/password, CSC) remain outside public JSON.
35
+ "stateRegistration",
36
+ "corporateName",
37
+ "tradeName",
38
+ "email",
39
+ "taxRegime",
40
+ "address",
41
+ "series",
42
+ "paymentMethod"
43
+ ];
44
+ var DAY_MS = 24 * 60 * 60 * 1e3;
45
+ function digits(value) {
46
+ return value.replace(/\D/g, "");
47
+ }
48
+ function isValidCnpj(value) {
49
+ const normalized = digits(value);
50
+ if (!/^\d{14}$/.test(normalized) || /^(\d)\1{13}$/.test(normalized)) return false;
51
+ const digit = (base, weights) => {
52
+ const sum = weights.reduce((total, weight, index) => total + Number(base[index]) * weight, 0);
53
+ const remainder = sum % 11;
54
+ return remainder < 2 ? 0 : 11 - remainder;
55
+ };
56
+ const first = digit(normalized.slice(0, 12), [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]);
57
+ const second = digit(`${normalized.slice(0, 12)}${first}`, [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]);
58
+ return normalized.endsWith(`${first}${second}`);
59
+ }
60
+ function environment(value) {
61
+ if (value === void 0 || value === null || value === "" || value === "sandbox") return "sandbox";
62
+ if (value === "homologation") return value;
63
+ throw new Error("Produ\xE7\xE3o n\xE3o est\xE1 dispon\xEDvel neste conector preview.");
64
+ }
65
+ function store() {
66
+ return createConnectionStore(PLUGNOTAS_CONNECTOR_ID, {
67
+ fields: [...PLUGNOTAS_FIELDS],
68
+ settings: [...PLUGNOTAS_SETTINGS],
69
+ settingsKeys: INTERNAL_SETTINGS
70
+ });
71
+ }
72
+ function deriveStatus(connection) {
73
+ if (!connection?.active) return { connected: false };
74
+ const readiness = settingsStatus([...PLUGNOTAS_SETTINGS], connection);
75
+ const certificateReady = connection.settings.certificateReady === true;
76
+ const cscReady = connection.settings.cscReady === true;
77
+ const expiry = typeof connection.settings.certificateExpiresAt === "string" ? Date.parse(connection.settings.certificateExpiresAt) : Number.NaN;
78
+ const daysUntilExpiry = Number.isFinite(expiry) ? Math.ceil((expiry - Date.now()) / DAY_MS) : null;
79
+ const expired = daysUntilExpiry !== null && daysUntilExpiry <= 0;
80
+ const expiringSoon = daysUntilExpiry !== null && daysUntilExpiry > 0 && daysUntilExpiry <= 30;
81
+ const missingSettings = [.../* @__PURE__ */ new Set([
82
+ ...readiness.missingSettings ?? [],
83
+ ...!certificateReady || expired ? ["certificate"] : [],
84
+ ...!cscReady ? ["csc"] : []
85
+ ])];
86
+ return {
87
+ connected: true,
88
+ needsSetup: missingSettings.length > 0,
89
+ ...missingSettings.length ? { missingSettings } : {},
90
+ detail: expired ? "Certificado A1 vencido" : !certificateReady ? "Envie o certificado A1 do emitente." : !cscReady ? "Confirme o CSC e o ID Token do emitente." : connection.settings.environment === "homologation" ? "Emitente pronto em homologa\xE7\xE3o" : "Emitente pronto no sandbox",
91
+ ...expiringSoon ? { issues: [`O certificado A1 vence em ${daysUntilExpiry} dia${daysUntilExpiry === 1 ? "" : "s"}.`] } : {},
92
+ plane: "sync"
93
+ };
94
+ }
95
+ function createPlugNotasConnector(options = {}) {
96
+ const connectionStore = store();
97
+ return {
98
+ id: PLUGNOTAS_CONNECTOR_ID,
99
+ // PlugBank and PlugNotas are different products from the same vendor. The
100
+ // directory key names the product, otherwise one silently replaces the
101
+ // other in the generated connector map.
102
+ catalogSlug: "plugnotas",
103
+ hostPluginId: "financial",
104
+ name: "PlugNotas \xB7 NFC-e",
105
+ description: "Emita NFC-e pela TecnoSpeed sem expor certificado, senha ou API key no navegador.",
106
+ icon: "ReceiptText",
107
+ logoUrl: PLUGNOTAS_MARK,
108
+ authKind: "api-key",
109
+ category: "erp",
110
+ direction: "outbound",
111
+ planes: ["sync"],
112
+ docsUrl: "https://docs.plugnotas.com.br/",
113
+ fields: [...PLUGNOTAS_FIELDS],
114
+ settings: [...PLUGNOTAS_SETTINGS],
115
+ permissions: [{
116
+ kind: "data",
117
+ resource: "fiscal-document",
118
+ actions: ["read", "write"],
119
+ reason: "Enviar a NFC-e e reconciliar situa\xE7\xE3o, chave de acesso e artefatos no Financeiro.",
120
+ sensitive: true
121
+ }],
122
+ invokedAt: [{
123
+ key: "financial.nfce.issue",
124
+ label: "Quando um operador confirma a emiss\xE3o da NFC-e de um pedido conclu\xEDdo",
125
+ plane: "sync",
126
+ trigger: "manual",
127
+ touches: ["fiscal-document"]
128
+ }],
129
+ howItSyncs: [
130
+ "A emiss\xE3o cria primeiro um comando dur\xE1vel e s\xF3 depois chama o provedor.",
131
+ "Webhook e recupera\xE7\xE3o consultiva convergem o mesmo comando, sem reenviar no escuro."
132
+ ],
133
+ ...connectionStatus(PLUGNOTAS_CONNECTOR_ID, deriveStatus, { store }),
134
+ ...connectionSettings(connectionStore),
135
+ async testConnection(values) {
136
+ const emitterCnpj = digits(values.emitterCnpj ?? "");
137
+ if (!isValidCnpj(emitterCnpj)) return { ok: false, message: "Informe um CNPJ v\xE1lido." };
138
+ let selected;
139
+ try {
140
+ selected = environment(values.environment);
141
+ } catch (error) {
142
+ return { ok: false, message: error instanceof Error ? error.message : "Ambiente inv\xE1lido." };
143
+ }
144
+ return options.client?.testEmitter ? options.client.testEmitter({ emitterCnpj, environment: selected }) : { ok: true };
145
+ },
146
+ async saveConnection(values) {
147
+ const emitterCnpj = digits(values.emitterCnpj ?? "");
148
+ if (!isValidCnpj(emitterCnpj)) throw new Error("Informe um CNPJ v\xE1lido.");
149
+ await connectionStore.save({
150
+ active: true,
151
+ displayName: `Emitente ${emitterCnpj.slice(-6)}`,
152
+ providerAccountKey: emitterCnpj,
153
+ settings: {
154
+ emitterCnpj,
155
+ environment: environment(values.environment),
156
+ certificateReady: false,
157
+ cscReady: false
158
+ }
159
+ });
160
+ },
161
+ ...options.client?.disconnectEmitter ? { disconnect: () => options.client.disconnectEmitter() } : {},
162
+ disconnectNote: "As notas existentes permanecem no Financeiro; somente novas emiss\xF5es s\xE3o interrompidas.",
163
+ ExtraPanel: options.SetupPanel
164
+ };
165
+ }
166
+ async function markPlugNotasEmitterReady(input) {
167
+ const emitterCnpj = digits(input.emitterCnpj);
168
+ if (!isValidCnpj(emitterCnpj)) throw new Error("Informe um CNPJ v\xE1lido.");
169
+ await store().save({
170
+ active: true,
171
+ providerAccountKey: emitterCnpj,
172
+ settings: {
173
+ emitterCnpj,
174
+ environment: environment(input.environment),
175
+ certificateReady: input.certificateReady,
176
+ cscReady: input.cscReady,
177
+ providerEmitterId: input.providerEmitterId,
178
+ certificateExpiresAt: input.certificateExpiresAt,
179
+ certificateFingerprint: input.certificateFingerprint
180
+ }
181
+ });
182
+ }
183
+
184
+ // src/migrations/index.ts
185
+ var MIGRATION_000_BASELINE = `-- Fiscal Brasil / PlugNotas baseline.
186
+ --
187
+ -- This addon does not create a second fiscal ledger. \`plg_financial_nfe\`
188
+ -- remains the authoritative document owned by plugin-financial. The tables
189
+ -- below describe delivery to an external issuer: durable commands, attempts,
190
+ -- webhooks and emitter ownership.
191
+ --
192
+ -- The file is additive and idempotent because the current restaurant pool is
193
+ -- live. Applying it is deliberately outside this draft PR.
194
+
195
+ CREATE TABLE IF NOT EXISTS public.plg_fiscalbr_nfce_jobs (
196
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
197
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
198
+ -- Logical reference to plugin-financial's fiscal ledger. Cross-plugin
199
+ -- physical foreign keys are intentionally forbidden: installation and
200
+ -- removal of this addon must never rewrite another plugin's schema.
201
+ nota_id uuid NOT NULL,
202
+ order_id uuid NOT NULL REFERENCES public.orders(id) ON DELETE RESTRICT,
203
+ connection_id uuid NOT NULL REFERENCES public.connections(id) ON DELETE RESTRICT,
204
+ operation text NOT NULL CHECK (operation IN ('issue', 'cancel')),
205
+ status text NOT NULL DEFAULT 'queued'
206
+ CHECK (status IN ('queued', 'running', 'waiting', 'succeeded', 'failed', 'manual_review')),
207
+ idempotency_key text NOT NULL,
208
+ provider_document_id text,
209
+ integration_id text,
210
+ request_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
211
+ provider_response jsonb NOT NULL DEFAULT '{}'::jsonb,
212
+ attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
213
+ next_attempt_at timestamptz,
214
+ locked_until timestamptz,
215
+ last_error text,
216
+ created_at timestamptz NOT NULL DEFAULT now(),
217
+ updated_at timestamptz NOT NULL DEFAULT now(),
218
+ UNIQUE (tenant_id, idempotency_key)
219
+ );
220
+
221
+ CREATE INDEX IF NOT EXISTS plg_fiscalbr_nfce_jobs_recovery_idx
222
+ ON public.plg_fiscalbr_nfce_jobs (status, next_attempt_at, locked_until)
223
+ WHERE status IN ('queued', 'waiting');
224
+
225
+ CREATE TABLE IF NOT EXISTS public.plg_fiscalbr_nfce_events (
226
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
227
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
228
+ nota_id uuid NOT NULL,
229
+ job_id uuid REFERENCES public.plg_fiscalbr_nfce_jobs(id) ON DELETE SET NULL,
230
+ event_type text NOT NULL,
231
+ payload jsonb NOT NULL DEFAULT '{}'::jsonb,
232
+ created_at timestamptz NOT NULL DEFAULT now()
233
+ );
234
+
235
+ CREATE INDEX IF NOT EXISTS plg_fiscalbr_nfce_events_document_idx
236
+ ON public.plg_fiscalbr_nfce_events (tenant_id, nota_id, created_at DESC);
237
+
238
+ CREATE TABLE IF NOT EXISTS public.plg_fiscalbr_webhook_receipts (
239
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
240
+ provider text NOT NULL DEFAULT 'plugnotas',
241
+ provider_event_id text NOT NULL,
242
+ tenant_id uuid REFERENCES public.tenants(id) ON DELETE SET NULL,
243
+ nota_id uuid,
244
+ payload_sha256 text NOT NULL,
245
+ received_at timestamptz NOT NULL DEFAULT now(),
246
+ processed_at timestamptz,
247
+ processing_error text,
248
+ UNIQUE (provider, provider_event_id)
249
+ );
250
+
251
+ CREATE TABLE IF NOT EXISTS public.plg_fiscalbr_emitter_ownership (
252
+ emitter_cnpj text PRIMARY KEY CHECK (emitter_cnpj ~ '^[0-9]{14}$'),
253
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
254
+ connection_id uuid NOT NULL REFERENCES public.connections(id) ON DELETE CASCADE,
255
+ environment text NOT NULL CHECK (environment IN ('sandbox', 'homologation')),
256
+ provider_emitter_id text,
257
+ certificate_expires_at timestamptz,
258
+ certificate_fingerprint text,
259
+ csc_ready boolean NOT NULL DEFAULT false,
260
+ created_at timestamptz NOT NULL DEFAULT now(),
261
+ updated_at timestamptz NOT NULL DEFAULT now(),
262
+ UNIQUE (tenant_id, connection_id)
263
+ );
264
+
265
+ ALTER TABLE public.plg_fiscalbr_nfce_jobs ENABLE ROW LEVEL SECURITY;
266
+ ALTER TABLE public.plg_fiscalbr_nfce_events ENABLE ROW LEVEL SECURITY;
267
+ ALTER TABLE public.plg_fiscalbr_webhook_receipts ENABLE ROW LEVEL SECURITY;
268
+ ALTER TABLE public.plg_fiscalbr_emitter_ownership ENABLE ROW LEVEL SECURITY;
269
+
270
+ DROP POLICY IF EXISTS plg_fiscalbr_nfce_jobs_read ON public.plg_fiscalbr_nfce_jobs;
271
+ CREATE POLICY plg_fiscalbr_nfce_jobs_read ON public.plg_fiscalbr_nfce_jobs
272
+ FOR SELECT TO authenticated
273
+ USING (
274
+ tenant_id = (SELECT app.current_tenant_id())
275
+ AND (SELECT app.has_permission('financial.read', NULL))
276
+ );
277
+
278
+ DROP POLICY IF EXISTS plg_fiscalbr_nfce_events_read ON public.plg_fiscalbr_nfce_events;
279
+ CREATE POLICY plg_fiscalbr_nfce_events_read ON public.plg_fiscalbr_nfce_events
280
+ FOR SELECT TO authenticated
281
+ USING (
282
+ tenant_id = (SELECT app.current_tenant_id())
283
+ AND (SELECT app.has_permission('financial.read', NULL))
284
+ );
285
+
286
+ DROP POLICY IF EXISTS plg_fiscalbr_emitter_ownership_read ON public.plg_fiscalbr_emitter_ownership;
287
+ CREATE POLICY plg_fiscalbr_emitter_ownership_read ON public.plg_fiscalbr_emitter_ownership
288
+ FOR SELECT TO authenticated
289
+ USING (
290
+ tenant_id = (SELECT app.current_tenant_id())
291
+ AND (SELECT app.has_permission('financial.read', NULL))
292
+ );
293
+
294
+ REVOKE ALL ON public.plg_fiscalbr_nfce_jobs FROM anon, authenticated;
295
+ REVOKE ALL ON public.plg_fiscalbr_nfce_events FROM anon, authenticated;
296
+ REVOKE ALL ON public.plg_fiscalbr_webhook_receipts FROM anon, authenticated;
297
+ REVOKE ALL ON public.plg_fiscalbr_emitter_ownership FROM anon, authenticated;
298
+ GRANT SELECT ON public.plg_fiscalbr_nfce_jobs TO authenticated;
299
+ GRANT SELECT ON public.plg_fiscalbr_nfce_events TO authenticated;
300
+ GRANT SELECT ON public.plg_fiscalbr_emitter_ownership TO authenticated;
301
+ GRANT ALL ON public.plg_fiscalbr_nfce_jobs TO service_role;
302
+ GRANT ALL ON public.plg_fiscalbr_nfce_events TO service_role;
303
+ GRANT ALL ON public.plg_fiscalbr_webhook_receipts TO service_role;
304
+ GRANT ALL ON public.plg_fiscalbr_emitter_ownership TO service_role;
305
+
306
+ CREATE OR REPLACE FUNCTION public.plugnotas_register_emitter(
307
+ p_tenant_id uuid,
308
+ p_connection_id uuid,
309
+ p_emitter_cnpj text,
310
+ p_environment text,
311
+ p_provider_emitter_id text DEFAULT NULL,
312
+ p_certificate_expires_at timestamptz DEFAULT NULL,
313
+ p_certificate_fingerprint text DEFAULT NULL,
314
+ p_csc_ready boolean DEFAULT false
315
+ ) RETURNS jsonb
316
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
317
+ DECLARE
318
+ v_cnpj text := regexp_replace(coalesce(p_emitter_cnpj, ''), '[^0-9]', '', 'g');
319
+ BEGIN
320
+ IF v_cnpj !~ '^[0-9]{14}$' THEN
321
+ RAISE EXCEPTION 'plugnotas_register_emitter: invalid CNPJ' USING ERRCODE = '22023';
322
+ END IF;
323
+ IF p_environment NOT IN ('sandbox', 'homologation') THEN
324
+ RAISE EXCEPTION 'plugnotas_register_emitter: production is not enabled' USING ERRCODE = '22023';
325
+ END IF;
326
+ IF NOT EXISTS (
327
+ SELECT 1 FROM public.connections c
328
+ WHERE c.id = p_connection_id
329
+ AND c.tenant_id = p_tenant_id
330
+ AND c.connector_id = 'plugnotas'
331
+ AND c.active
332
+ ) THEN
333
+ RAISE EXCEPTION 'plugnotas_register_emitter: active connection not found' USING ERRCODE = 'P0002';
334
+ END IF;
335
+
336
+ INSERT INTO public.plg_fiscalbr_emitter_ownership
337
+ (emitter_cnpj, tenant_id, connection_id, environment, provider_emitter_id,
338
+ certificate_expires_at, certificate_fingerprint, csc_ready, updated_at)
339
+ VALUES
340
+ (v_cnpj, p_tenant_id, p_connection_id, p_environment, p_provider_emitter_id,
341
+ p_certificate_expires_at, p_certificate_fingerprint, p_csc_ready, now())
342
+ ON CONFLICT (emitter_cnpj) DO UPDATE SET
343
+ tenant_id = EXCLUDED.tenant_id,
344
+ connection_id = EXCLUDED.connection_id,
345
+ environment = EXCLUDED.environment,
346
+ provider_emitter_id = EXCLUDED.provider_emitter_id,
347
+ certificate_expires_at = EXCLUDED.certificate_expires_at,
348
+ certificate_fingerprint = EXCLUDED.certificate_fingerprint,
349
+ csc_ready = EXCLUDED.csc_ready,
350
+ updated_at = now()
351
+ WHERE public.plg_fiscalbr_emitter_ownership.tenant_id = EXCLUDED.tenant_id;
352
+
353
+ IF NOT FOUND THEN
354
+ RAISE EXCEPTION 'plugnotas_register_emitter: CNPJ belongs to another tenant' USING ERRCODE = '23505';
355
+ END IF;
356
+
357
+ UPDATE public.connections
358
+ SET provider_account_key = v_cnpj,
359
+ settings = settings || jsonb_build_object(
360
+ 'environment', p_environment,
361
+ 'certificateReady', p_certificate_expires_at IS NOT NULL,
362
+ 'certificateExpiresAt', p_certificate_expires_at,
363
+ 'certificateFingerprint', p_certificate_fingerprint,
364
+ 'providerEmitterId', p_provider_emitter_id,
365
+ 'cscReady', p_csc_ready
366
+ ),
367
+ status = CASE WHEN p_certificate_expires_at IS NOT NULL AND p_csc_ready THEN 'connected' ELSE 'pending' END,
368
+ updated_at = now()
369
+ WHERE id = p_connection_id AND tenant_id = p_tenant_id;
370
+
371
+ RETURN jsonb_build_object('emitterCnpj', v_cnpj, 'environment', p_environment);
372
+ END $$;
373
+
374
+ CREATE OR REPLACE FUNCTION public.plugnotas_disconnect_emitter(
375
+ p_tenant_id uuid,
376
+ p_connection_id uuid
377
+ ) RETURNS void
378
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
379
+ BEGIN
380
+ DELETE FROM public.plg_fiscalbr_emitter_ownership
381
+ WHERE tenant_id = p_tenant_id AND connection_id = p_connection_id;
382
+ UPDATE public.connections
383
+ SET active = false, status = 'revoked', settings = '{}'::jsonb,
384
+ provider_state = '{}'::jsonb, updated_at = now()
385
+ WHERE tenant_id = p_tenant_id AND id = p_connection_id AND connector_id = 'plugnotas';
386
+ END $$;
387
+
388
+ CREATE OR REPLACE FUNCTION public.plugnotas_create_nfce_draft_for_order(
389
+ p_tenant_id uuid,
390
+ p_order_id uuid
391
+ ) RETURNS uuid
392
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
393
+ DECLARE
394
+ v_order public.orders%ROWTYPE;
395
+ v_nota_id uuid;
396
+ BEGIN
397
+ -- Serialize draft creation per tenant/order without adding a column or
398
+ -- index to the financial plugin's table. The order itself remains the
399
+ -- stable idempotency boundary for one NFC-e draft.
400
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || ':' || p_order_id::text, 0));
401
+
402
+ SELECT * INTO v_order FROM public.orders
403
+ WHERE tenant_id = p_tenant_id AND id = p_order_id;
404
+ IF NOT FOUND THEN
405
+ RAISE EXCEPTION 'plugnotas_create_nfce_draft_for_order: order not found' USING ERRCODE = 'P0002';
406
+ END IF;
407
+ IF v_order.status NOT IN ('completed', 'paid', 'invoiced') THEN
408
+ RAISE EXCEPTION 'plugnotas_create_nfce_draft_for_order: order is not closed' USING ERRCODE = '23514';
409
+ END IF;
410
+ IF NOT EXISTS (SELECT 1 FROM public.items i WHERE i.tenant_id = p_tenant_id AND i.order_id = p_order_id) THEN
411
+ RAISE EXCEPTION 'plugnotas_create_nfce_draft_for_order: order has no items' USING ERRCODE = '23514';
412
+ END IF;
413
+
414
+ SELECT n.id INTO v_nota_id FROM public.plg_financial_nfe n
415
+ WHERE n.tenant_id = p_tenant_id AND n.order_id = p_order_id AND n.model = 'nfce'
416
+ ORDER BY n.created_at DESC LIMIT 1;
417
+ IF v_nota_id IS NOT NULL THEN RETURN v_nota_id; END IF;
418
+
419
+ INSERT INTO public.plg_financial_nfe
420
+ (tenant_id, unit_id, model, status, order_id, contact_id, total_amount,
421
+ emissor, metadata)
422
+ VALUES
423
+ (p_tenant_id, v_order.unit_id, 'nfce', 'draft', p_order_id, v_order.party_id,
424
+ coalesce(v_order.total, 0), 'plugnotas',
425
+ jsonb_build_object('source', 'order', 'orderKind', v_order.kind, 'orderChannel', v_order.channel))
426
+ RETURNING id INTO v_nota_id;
427
+ RETURN v_nota_id;
428
+ END $$;
429
+
430
+ CREATE OR REPLACE FUNCTION public.plugnotas_prepare_nfce(
431
+ p_tenant_id uuid,
432
+ p_nota_id uuid,
433
+ p_connection_id uuid,
434
+ p_idempotency_key text
435
+ ) RETURNS jsonb
436
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
437
+ DECLARE
438
+ v_nota public.plg_financial_nfe%ROWTYPE;
439
+ v_job public.plg_fiscalbr_nfce_jobs%ROWTYPE;
440
+ v_snapshot jsonb;
441
+ BEGIN
442
+ SELECT * INTO v_nota FROM public.plg_financial_nfe
443
+ WHERE tenant_id = p_tenant_id AND id = p_nota_id FOR UPDATE;
444
+ IF NOT FOUND OR v_nota.model <> 'nfce' OR v_nota.order_id IS NULL THEN
445
+ RAISE EXCEPTION 'plugnotas_prepare_nfce: order-backed NFC-e not found' USING ERRCODE = 'P0002';
446
+ END IF;
447
+ IF v_nota.status NOT IN ('draft', 'error', 'processing') THEN
448
+ RAISE EXCEPTION 'plugnotas_prepare_nfce: invalid document status %', v_nota.status USING ERRCODE = '23514';
449
+ END IF;
450
+ IF NOT EXISTS (
451
+ SELECT 1 FROM public.plg_fiscalbr_emitter_ownership e
452
+ WHERE e.tenant_id = p_tenant_id AND e.connection_id = p_connection_id
453
+ AND e.csc_ready AND e.certificate_expires_at > now()
454
+ ) THEN
455
+ RAISE EXCEPTION 'plugnotas_prepare_nfce: emitter is not ready' USING ERRCODE = '23514';
456
+ END IF;
457
+
458
+ SELECT jsonb_build_object(
459
+ 'nota', to_jsonb(v_nota),
460
+ 'order', to_jsonb(o),
461
+ 'items', coalesce((
462
+ SELECT jsonb_agg(to_jsonb(i) ORDER BY i.sort_order, i.id)
463
+ FROM public.items i
464
+ WHERE i.tenant_id = p_tenant_id AND i.order_id = v_nota.order_id
465
+ ), '[]'::jsonb),
466
+ -- Product tax metadata must be frozen in the same transaction as the order
467
+ -- lines. Looking products up in the Edge Function would let a retry emit
468
+ -- with NCM/CFOP changed after the operator confirmed the document.
469
+ 'products', coalesce((
470
+ SELECT jsonb_agg(to_jsonb(p) ORDER BY p.id)
471
+ FROM public.products p
472
+ WHERE p.tenant_id = p_tenant_id
473
+ AND p.id IN (
474
+ SELECT i.product_id FROM public.items i
475
+ WHERE i.tenant_id = p_tenant_id
476
+ AND i.order_id = v_nota.order_id
477
+ AND i.product_id IS NOT NULL
478
+ )
479
+ ), '[]'::jsonb)
480
+ ) INTO v_snapshot
481
+ FROM public.orders o
482
+ WHERE o.tenant_id = p_tenant_id AND o.id = v_nota.order_id;
483
+
484
+ IF jsonb_array_length(v_snapshot->'items') = 0 THEN
485
+ RAISE EXCEPTION 'plugnotas_prepare_nfce: order has no items' USING ERRCODE = '23514';
486
+ END IF;
487
+
488
+ INSERT INTO public.plg_fiscalbr_nfce_jobs
489
+ (tenant_id, nota_id, order_id, connection_id, operation, status,
490
+ idempotency_key, request_snapshot, next_attempt_at, updated_at)
491
+ VALUES
492
+ (p_tenant_id, p_nota_id, v_nota.order_id, p_connection_id, 'issue', 'queued',
493
+ p_idempotency_key, v_snapshot, now(), now())
494
+ ON CONFLICT (tenant_id, idempotency_key) DO UPDATE
495
+ SET updated_at = now()
496
+ RETURNING * INTO v_job;
497
+
498
+ UPDATE public.plg_financial_nfe
499
+ SET status = 'processing', error_message = NULL,
500
+ metadata = metadata || jsonb_build_object('plugnotas', jsonb_build_object(
501
+ 'jobId', v_job.id, 'jobStatus', v_job.status, 'operation', 'issue'
502
+ )), updated_at = now()
503
+ WHERE id = p_nota_id AND tenant_id = p_tenant_id;
504
+
505
+ INSERT INTO public.plg_fiscalbr_nfce_events (tenant_id, nota_id, job_id, event_type, payload)
506
+ VALUES (p_tenant_id, p_nota_id, v_job.id, 'issue.queued', '{}'::jsonb);
507
+ RETURN jsonb_build_object('jobId', v_job.id, 'snapshot', v_job.request_snapshot, 'replayed', v_job.attempts > 0);
508
+ END $$;
509
+
510
+ CREATE OR REPLACE FUNCTION public.plugnotas_prepare_nfce_cancel(
511
+ p_tenant_id uuid,
512
+ p_nota_id uuid,
513
+ p_connection_id uuid,
514
+ p_reason text
515
+ ) RETURNS jsonb
516
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
517
+ DECLARE
518
+ v_nota public.plg_financial_nfe%ROWTYPE;
519
+ v_job_id uuid;
520
+ v_reason text := btrim(coalesce(p_reason, ''));
521
+ BEGIN
522
+ IF char_length(v_reason) NOT BETWEEN 15 AND 255 THEN
523
+ RAISE EXCEPTION 'plugnotas_prepare_nfce_cancel: reason must contain 15 to 255 characters' USING ERRCODE = '22023';
524
+ END IF;
525
+ SELECT * INTO v_nota FROM public.plg_financial_nfe
526
+ WHERE tenant_id = p_tenant_id AND id = p_nota_id FOR UPDATE;
527
+ IF NOT FOUND OR v_nota.status <> 'issued' OR v_nota.access_key IS NULL THEN
528
+ RAISE EXCEPTION 'plugnotas_prepare_nfce_cancel: authorized NFC-e not found' USING ERRCODE = '23514';
529
+ END IF;
530
+
531
+ INSERT INTO public.plg_fiscalbr_nfce_jobs
532
+ (tenant_id, nota_id, order_id, connection_id, operation, status,
533
+ idempotency_key, provider_document_id, integration_id,
534
+ request_snapshot, next_attempt_at)
535
+ VALUES
536
+ (p_tenant_id, p_nota_id, v_nota.order_id, p_connection_id, 'cancel', 'queued',
537
+ 'nfce-cancel:' || p_nota_id::text,
538
+ v_nota.metadata #>> '{plugnotas,providerDocumentId}',
539
+ v_nota.metadata #>> '{plugnotas,integrationId}',
540
+ jsonb_build_object('reason', v_reason), now())
541
+ ON CONFLICT (tenant_id, idempotency_key) DO UPDATE SET
542
+ provider_document_id = coalesce(
543
+ public.plg_fiscalbr_nfce_jobs.provider_document_id,
544
+ EXCLUDED.provider_document_id
545
+ ),
546
+ integration_id = coalesce(
547
+ public.plg_fiscalbr_nfce_jobs.integration_id,
548
+ EXCLUDED.integration_id
549
+ ),
550
+ updated_at = now()
551
+ RETURNING id INTO v_job_id;
552
+
553
+ UPDATE public.plg_financial_nfe
554
+ SET metadata = metadata || jsonb_build_object('plugnotas', jsonb_build_object(
555
+ 'jobId', v_job_id, 'jobStatus', 'queued', 'operation', 'cancel', 'cancellationPending', true
556
+ )), updated_at = now()
557
+ WHERE id = p_nota_id AND tenant_id = p_tenant_id;
558
+ RETURN jsonb_build_object('jobId', v_job_id, 'reason', v_reason);
559
+ END $$;
560
+
561
+ CREATE OR REPLACE FUNCTION public.plugnotas_record_nfce_attempt(
562
+ p_tenant_id uuid,
563
+ p_job_id uuid,
564
+ p_status text,
565
+ p_provider_response jsonb DEFAULT '{}'::jsonb,
566
+ p_error text DEFAULT NULL,
567
+ p_next_attempt_at timestamptz DEFAULT NULL
568
+ ) RETURNS void
569
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
570
+ BEGIN
571
+ IF p_status NOT IN ('running', 'waiting', 'failed', 'manual_review') THEN
572
+ RAISE EXCEPTION 'plugnotas_record_nfce_attempt: invalid status' USING ERRCODE = '22023';
573
+ END IF;
574
+ UPDATE public.plg_fiscalbr_nfce_jobs
575
+ SET status = p_status,
576
+ -- One provider dispatch is one attempt. Moving that same attempt from
577
+ -- running to waiting/failed must not count it a second time.
578
+ attempts = attempts + CASE WHEN p_status = 'running' THEN 1 ELSE 0 END,
579
+ provider_response = coalesce(p_provider_response, '{}'::jsonb),
580
+ last_error = p_error, next_attempt_at = p_next_attempt_at,
581
+ locked_until = NULL, updated_at = now()
582
+ WHERE tenant_id = p_tenant_id AND id = p_job_id;
583
+ END $$;
584
+
585
+ CREATE OR REPLACE FUNCTION public.plugnotas_apply_nfce_result(
586
+ p_tenant_id uuid,
587
+ p_job_id uuid,
588
+ p_status text,
589
+ p_provider_document_id text DEFAULT NULL,
590
+ p_integration_id text DEFAULT NULL,
591
+ p_number text DEFAULT NULL,
592
+ p_series text DEFAULT NULL,
593
+ p_access_key text DEFAULT NULL,
594
+ p_message text DEFAULT NULL,
595
+ p_provider_response jsonb DEFAULT '{}'::jsonb
596
+ ) RETURNS uuid
597
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
598
+ DECLARE
599
+ v_job public.plg_fiscalbr_nfce_jobs%ROWTYPE;
600
+ v_document_status text;
601
+ BEGIN
602
+ SELECT * INTO v_job FROM public.plg_fiscalbr_nfce_jobs
603
+ WHERE tenant_id = p_tenant_id AND id = p_job_id FOR UPDATE;
604
+ IF NOT FOUND THEN
605
+ RAISE EXCEPTION 'plugnotas_apply_nfce_result: job not found' USING ERRCODE = 'P0002';
606
+ END IF;
607
+ IF p_status NOT IN ('processing', 'issued', 'cancelled', 'error') THEN
608
+ RAISE EXCEPTION 'plugnotas_apply_nfce_result: invalid status' USING ERRCODE = '22023';
609
+ END IF;
610
+
611
+ v_document_status := CASE
612
+ WHEN v_job.operation = 'cancel' AND p_status = 'processing' THEN 'issued'
613
+ ELSE p_status
614
+ END;
615
+ UPDATE public.plg_fiscalbr_nfce_jobs
616
+ SET status = CASE
617
+ WHEN p_status IN ('issued', 'cancelled') THEN 'succeeded'
618
+ WHEN p_status = 'error' THEN 'failed'
619
+ ELSE 'waiting'
620
+ END,
621
+ provider_document_id = coalesce(p_provider_document_id, provider_document_id),
622
+ integration_id = coalesce(p_integration_id, integration_id),
623
+ provider_response = coalesce(p_provider_response, '{}'::jsonb),
624
+ last_error = CASE WHEN p_status = 'error' THEN p_message ELSE NULL END,
625
+ next_attempt_at = CASE WHEN p_status = 'processing' THEN now() + interval '2 minutes' ELSE NULL END,
626
+ locked_until = NULL, updated_at = now()
627
+ WHERE id = p_job_id;
628
+
629
+ UPDATE public.plg_financial_nfe
630
+ SET status = v_document_status,
631
+ number = coalesce(p_number, number),
632
+ series = coalesce(p_series, series),
633
+ access_key = coalesce(p_access_key, access_key),
634
+ issued_at = CASE WHEN p_status = 'issued' THEN coalesce(issued_at, now()) ELSE issued_at END,
635
+ error_message = CASE WHEN p_status = 'error' THEN p_message ELSE NULL END,
636
+ metadata = metadata || jsonb_build_object('plugnotas', jsonb_strip_nulls(jsonb_build_object(
637
+ 'jobId', p_job_id,
638
+ 'jobStatus', CASE WHEN p_status IN ('issued', 'cancelled') THEN 'succeeded' WHEN p_status = 'error' THEN 'failed' ELSE 'waiting' END,
639
+ 'operation', v_job.operation,
640
+ 'providerDocumentId', coalesce(p_provider_document_id, v_job.provider_document_id),
641
+ 'integrationId', coalesce(p_integration_id, v_job.integration_id),
642
+ 'cancellationPending', v_job.operation = 'cancel' AND p_status = 'processing'
643
+ ))),
644
+ updated_at = now()
645
+ WHERE tenant_id = p_tenant_id AND id = v_job.nota_id;
646
+
647
+ INSERT INTO public.plg_fiscalbr_nfce_events (tenant_id, nota_id, job_id, event_type, payload)
648
+ VALUES (p_tenant_id, v_job.nota_id, p_job_id, v_job.operation || '.' || p_status,
649
+ coalesce(p_provider_response, '{}'::jsonb));
650
+ RETURN v_job.nota_id;
651
+ END $$;
652
+
653
+ CREATE OR REPLACE FUNCTION public.plugnotas_apply_nfce_webhook(
654
+ p_provider_event_id text,
655
+ p_payload_sha256 text,
656
+ p_provider_document_id text,
657
+ p_status text,
658
+ p_integration_id text DEFAULT NULL,
659
+ p_number text DEFAULT NULL,
660
+ p_series text DEFAULT NULL,
661
+ p_access_key text DEFAULT NULL,
662
+ p_message text DEFAULT NULL,
663
+ p_payload jsonb DEFAULT '{}'::jsonb
664
+ ) RETURNS jsonb
665
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
666
+ DECLARE
667
+ v_job public.plg_fiscalbr_nfce_jobs%ROWTYPE;
668
+ v_receipt_id bigint;
669
+ BEGIN
670
+ INSERT INTO public.plg_fiscalbr_webhook_receipts
671
+ (provider_event_id, payload_sha256)
672
+ VALUES (p_provider_event_id, p_payload_sha256)
673
+ ON CONFLICT (provider, provider_event_id) DO NOTHING
674
+ RETURNING id INTO v_receipt_id;
675
+ IF v_receipt_id IS NULL THEN RETURN jsonb_build_object('duplicate', true); END IF;
676
+
677
+ SELECT * INTO v_job FROM public.plg_fiscalbr_nfce_jobs
678
+ WHERE provider_document_id = p_provider_document_id
679
+ ORDER BY created_at DESC LIMIT 1;
680
+ IF NOT FOUND THEN
681
+ UPDATE public.plg_fiscalbr_webhook_receipts
682
+ SET processed_at = now(), processing_error = 'job_not_found'
683
+ WHERE id = v_receipt_id;
684
+ RETURN jsonb_build_object('duplicate', false, 'matched', false);
685
+ END IF;
686
+
687
+ UPDATE public.plg_fiscalbr_webhook_receipts
688
+ SET tenant_id = v_job.tenant_id, nota_id = v_job.nota_id, processed_at = now()
689
+ WHERE id = v_receipt_id;
690
+ PERFORM public.plugnotas_apply_nfce_result(
691
+ v_job.tenant_id, v_job.id, p_status, p_provider_document_id,
692
+ coalesce(p_integration_id, v_job.integration_id), p_number, p_series,
693
+ p_access_key, p_message, p_payload
694
+ );
695
+ RETURN jsonb_build_object('duplicate', false, 'matched', true, 'jobId', v_job.id);
696
+ END $$;
697
+
698
+ CREATE OR REPLACE FUNCTION public.plugnotas_get_artifact_locator(
699
+ p_tenant_id uuid,
700
+ p_nota_id uuid
701
+ ) RETURNS jsonb
702
+ LANGUAGE sql SECURITY DEFINER SET search_path = '' STABLE AS $$
703
+ SELECT jsonb_build_object(
704
+ 'notaId', n.id,
705
+ 'status', n.status,
706
+ 'providerDocumentId', n.metadata #>> '{plugnotas,providerDocumentId}',
707
+ 'integrationId', n.metadata #>> '{plugnotas,integrationId}'
708
+ )
709
+ FROM public.plg_financial_nfe n
710
+ WHERE n.tenant_id = p_tenant_id AND n.id = p_nota_id
711
+ AND n.status IN ('issued', 'cancelled');
712
+ $$;
713
+
714
+ CREATE OR REPLACE FUNCTION public.plugnotas_claim_nfce_recovery(
715
+ p_limit integer DEFAULT 20
716
+ ) RETURNS SETOF public.plg_fiscalbr_nfce_jobs
717
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
718
+ BEGIN
719
+ RETURN QUERY
720
+ WITH candidates AS (
721
+ SELECT j.id FROM public.plg_fiscalbr_nfce_jobs j
722
+ WHERE j.status IN ('queued', 'waiting')
723
+ AND coalesce(j.next_attempt_at, j.created_at) <= now()
724
+ AND coalesce(j.locked_until, '-infinity'::timestamptz) < now()
725
+ ORDER BY coalesce(j.next_attempt_at, j.created_at), j.created_at
726
+ FOR UPDATE SKIP LOCKED
727
+ LIMIT greatest(1, least(coalesce(p_limit, 20), 100))
728
+ )
729
+ UPDATE public.plg_fiscalbr_nfce_jobs j
730
+ SET status = 'running', locked_until = now() + interval '2 minutes', updated_at = now()
731
+ FROM candidates c WHERE j.id = c.id
732
+ RETURNING j.*;
733
+ END $$;
734
+
735
+ REVOKE ALL ON FUNCTION public.plugnotas_register_emitter(uuid, uuid, text, text, text, timestamptz, text, boolean) FROM PUBLIC, anon, authenticated;
736
+ REVOKE ALL ON FUNCTION public.plugnotas_disconnect_emitter(uuid, uuid) FROM PUBLIC, anon, authenticated;
737
+ REVOKE ALL ON FUNCTION public.plugnotas_create_nfce_draft_for_order(uuid, uuid) FROM PUBLIC, anon, authenticated;
738
+ REVOKE ALL ON FUNCTION public.plugnotas_prepare_nfce(uuid, uuid, uuid, text) FROM PUBLIC, anon, authenticated;
739
+ REVOKE ALL ON FUNCTION public.plugnotas_prepare_nfce_cancel(uuid, uuid, uuid, text) FROM PUBLIC, anon, authenticated;
740
+ REVOKE ALL ON FUNCTION public.plugnotas_record_nfce_attempt(uuid, uuid, text, jsonb, text, timestamptz) FROM PUBLIC, anon, authenticated;
741
+ REVOKE ALL ON FUNCTION public.plugnotas_apply_nfce_result(uuid, uuid, text, text, text, text, text, text, text, jsonb) FROM PUBLIC, anon, authenticated;
742
+ REVOKE ALL ON FUNCTION public.plugnotas_apply_nfce_webhook(text, text, text, text, text, text, text, text, text, jsonb) FROM PUBLIC, anon, authenticated;
743
+ REVOKE ALL ON FUNCTION public.plugnotas_get_artifact_locator(uuid, uuid) FROM PUBLIC, anon, authenticated;
744
+ REVOKE ALL ON FUNCTION public.plugnotas_claim_nfce_recovery(integer) FROM PUBLIC, anon, authenticated;
745
+
746
+ GRANT EXECUTE ON FUNCTION public.plugnotas_register_emitter(uuid, uuid, text, text, text, timestamptz, text, boolean) TO service_role;
747
+ GRANT EXECUTE ON FUNCTION public.plugnotas_disconnect_emitter(uuid, uuid) TO service_role;
748
+ GRANT EXECUTE ON FUNCTION public.plugnotas_create_nfce_draft_for_order(uuid, uuid) TO service_role;
749
+ GRANT EXECUTE ON FUNCTION public.plugnotas_prepare_nfce(uuid, uuid, uuid, text) TO service_role;
750
+ GRANT EXECUTE ON FUNCTION public.plugnotas_prepare_nfce_cancel(uuid, uuid, uuid, text) TO service_role;
751
+ GRANT EXECUTE ON FUNCTION public.plugnotas_record_nfce_attempt(uuid, uuid, text, jsonb, text, timestamptz) TO service_role;
752
+ GRANT EXECUTE ON FUNCTION public.plugnotas_apply_nfce_result(uuid, uuid, text, text, text, text, text, text, text, jsonb) TO service_role;
753
+ GRANT EXECUTE ON FUNCTION public.plugnotas_apply_nfce_webhook(text, text, text, text, text, text, text, text, text, jsonb) TO service_role;
754
+ GRANT EXECUTE ON FUNCTION public.plugnotas_get_artifact_locator(uuid, uuid) TO service_role;
755
+ GRANT EXECUTE ON FUNCTION public.plugnotas_claim_nfce_recovery(integer) TO service_role;
756
+ `;
757
+ var MIGRATION_001_PRESERVE_TERMINAL_RESULTS_AND_RECOVER_LEASES = `-- Incremental recovery fix. Preserve 000_baseline.sql and its recorded checksum.
758
+ -- Only existing fiscal functions change; no parallel ledger/schema or backfill.
759
+ -- CREATE OR REPLACE retains the service-role-only ACL from the baseline.
760
+
761
+ CREATE OR REPLACE FUNCTION public.plugnotas_record_nfce_attempt(
762
+ p_tenant_id uuid,
763
+ p_job_id uuid,
764
+ p_status text,
765
+ p_provider_response jsonb DEFAULT '{}'::jsonb,
766
+ p_error text DEFAULT NULL,
767
+ p_next_attempt_at timestamptz DEFAULT NULL
768
+ ) RETURNS void
769
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
770
+ BEGIN
771
+ IF p_status NOT IN ('running', 'waiting', 'failed', 'manual_review') THEN
772
+ RAISE EXCEPTION 'plugnotas_record_nfce_attempt: invalid status' USING ERRCODE = '22023';
773
+ END IF;
774
+ UPDATE public.plg_fiscalbr_nfce_jobs
775
+ SET status = p_status,
776
+ -- One provider dispatch is one attempt. Moving that same attempt from
777
+ -- running to waiting/failed must not count it a second time.
778
+ attempts = attempts + CASE WHEN p_status = 'running' THEN 1 ELSE 0 END,
779
+ provider_response = coalesce(p_provider_response, '{}'::jsonb),
780
+ last_error = p_error, next_attempt_at = p_next_attempt_at,
781
+ locked_until = CASE WHEN p_status = 'running'
782
+ THEN greatest(coalesce(locked_until, now()), now() + interval '2 minutes') ELSE NULL END,
783
+ updated_at = now()
784
+ WHERE tenant_id = p_tenant_id AND id = p_job_id AND status <> 'succeeded';
785
+ END $$;
786
+
787
+ CREATE OR REPLACE FUNCTION public.plugnotas_apply_nfce_result(
788
+ p_tenant_id uuid,
789
+ p_job_id uuid,
790
+ p_status text,
791
+ p_provider_document_id text DEFAULT NULL,
792
+ p_integration_id text DEFAULT NULL,
793
+ p_number text DEFAULT NULL,
794
+ p_series text DEFAULT NULL,
795
+ p_access_key text DEFAULT NULL,
796
+ p_message text DEFAULT NULL,
797
+ p_provider_response jsonb DEFAULT '{}'::jsonb
798
+ ) RETURNS uuid
799
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
800
+ DECLARE
801
+ v_job public.plg_fiscalbr_nfce_jobs%ROWTYPE;
802
+ v_document_status text;
803
+ v_existing_status text;
804
+ v_job_status text;
805
+ BEGIN
806
+ SELECT * INTO v_job FROM public.plg_fiscalbr_nfce_jobs
807
+ WHERE tenant_id = p_tenant_id AND id = p_job_id;
808
+ IF NOT FOUND THEN
809
+ RAISE EXCEPTION 'plugnotas_apply_nfce_result: job not found' USING ERRCODE = 'P0002';
810
+ END IF;
811
+ IF p_status NOT IN ('processing', 'issued', 'cancelled', 'error') THEN
812
+ RAISE EXCEPTION 'plugnotas_apply_nfce_result: invalid status' USING ERRCODE = '22023';
813
+ END IF;
814
+
815
+ -- Match prepare/cancel's lock order: document, then job.
816
+ SELECT status INTO v_existing_status FROM public.plg_financial_nfe
817
+ WHERE tenant_id = p_tenant_id AND id = v_job.nota_id FOR UPDATE;
818
+ IF NOT FOUND THEN
819
+ RAISE EXCEPTION 'plugnotas_apply_nfce_result: document not found' USING ERRCODE = 'P0002';
820
+ END IF;
821
+ SELECT * INTO v_job FROM public.plg_fiscalbr_nfce_jobs
822
+ WHERE tenant_id = p_tenant_id AND id = p_job_id FOR UPDATE;
823
+
824
+ -- Late polling must not regress a concluded document or an active cancellation.
825
+ IF v_existing_status = 'cancelled'
826
+ OR (v_job.operation = 'issue' AND v_existing_status = 'issued' AND p_status <> 'cancelled')
827
+ OR (v_job.status = 'succeeded' AND p_status IN ('processing', 'error')) THEN
828
+ -- Old/interrupted jobs may still be running after the document concluded.
829
+ -- Settle their lease too, otherwise recovery would claim them forever.
830
+ UPDATE public.plg_fiscalbr_nfce_jobs
831
+ SET status = 'succeeded', locked_until = NULL, next_attempt_at = NULL, updated_at = now()
832
+ WHERE tenant_id = p_tenant_id AND id = p_job_id;
833
+ RETURN v_job.nota_id;
834
+ END IF;
835
+
836
+ v_job_status := CASE
837
+ WHEN p_status = 'cancelled' OR (v_job.operation = 'issue' AND p_status = 'issued') THEN 'succeeded'
838
+ WHEN p_status = 'error' THEN 'failed'
839
+ ELSE 'waiting' END;
840
+ v_document_status := CASE
841
+ WHEN v_job.operation = 'cancel' AND p_status <> 'cancelled' THEN v_existing_status
842
+ ELSE p_status
843
+ END;
844
+ UPDATE public.plg_fiscalbr_nfce_jobs
845
+ SET status = v_job_status,
846
+ provider_document_id = coalesce(p_provider_document_id, provider_document_id),
847
+ integration_id = coalesce(p_integration_id, integration_id),
848
+ provider_response = coalesce(p_provider_response, '{}'::jsonb),
849
+ last_error = CASE WHEN p_status = 'error' THEN p_message ELSE NULL END,
850
+ next_attempt_at = CASE WHEN v_job_status = 'waiting' THEN now() + interval '2 minutes' ELSE NULL END,
851
+ locked_until = NULL, updated_at = now()
852
+ WHERE id = p_job_id;
853
+
854
+ UPDATE public.plg_financial_nfe
855
+ SET status = v_document_status,
856
+ number = coalesce(p_number, number),
857
+ series = coalesce(p_series, series),
858
+ access_key = coalesce(p_access_key, access_key),
859
+ issued_at = CASE WHEN p_status = 'issued' THEN coalesce(issued_at, now()) ELSE issued_at END,
860
+ error_message = CASE WHEN v_document_status = 'error' THEN p_message ELSE NULL END,
861
+ metadata = metadata || jsonb_build_object('plugnotas', jsonb_strip_nulls(jsonb_build_object(
862
+ 'jobId', p_job_id,
863
+ 'jobStatus', v_job_status,
864
+ 'operation', v_job.operation,
865
+ 'providerDocumentId', coalesce(p_provider_document_id, v_job.provider_document_id),
866
+ 'integrationId', coalesce(p_integration_id, v_job.integration_id),
867
+ 'cancellationPending', v_job.operation = 'cancel' AND v_job_status = 'waiting'
868
+ ))),
869
+ updated_at = now()
870
+ WHERE tenant_id = p_tenant_id AND id = v_job.nota_id;
871
+
872
+ INSERT INTO public.plg_fiscalbr_nfce_events (tenant_id, nota_id, job_id, event_type, payload)
873
+ VALUES (p_tenant_id, v_job.nota_id, p_job_id, v_job.operation || '.' || p_status,
874
+ coalesce(p_provider_response, '{}'::jsonb));
875
+ RETURN v_job.nota_id;
876
+ END $$;
877
+
878
+ CREATE OR REPLACE FUNCTION public.plugnotas_apply_nfce_webhook(
879
+ p_provider_event_id text,
880
+ p_payload_sha256 text,
881
+ p_provider_document_id text,
882
+ p_status text,
883
+ p_integration_id text DEFAULT NULL,
884
+ p_number text DEFAULT NULL,
885
+ p_series text DEFAULT NULL,
886
+ p_access_key text DEFAULT NULL,
887
+ p_message text DEFAULT NULL,
888
+ p_payload jsonb DEFAULT '{}'::jsonb
889
+ ) RETURNS jsonb
890
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
891
+ DECLARE
892
+ v_job public.plg_fiscalbr_nfce_jobs%ROWTYPE;
893
+ v_receipt_id bigint;
894
+ v_receipt public.plg_fiscalbr_webhook_receipts%ROWTYPE;
895
+ BEGIN
896
+ INSERT INTO public.plg_fiscalbr_webhook_receipts
897
+ (provider_event_id, payload_sha256)
898
+ VALUES (p_provider_event_id, p_payload_sha256)
899
+ ON CONFLICT (provider, provider_event_id) DO NOTHING
900
+ RETURNING id INTO v_receipt_id;
901
+ SELECT * INTO v_receipt FROM public.plg_fiscalbr_webhook_receipts
902
+ WHERE provider = 'plugnotas' AND provider_event_id = p_provider_event_id FOR UPDATE;
903
+ v_receipt_id := v_receipt.id;
904
+ IF v_receipt.payload_sha256 IS DISTINCT FROM p_payload_sha256 THEN
905
+ RAISE EXCEPTION 'plugnotas_apply_nfce_webhook: conflicting event payload' USING ERRCODE = '22023';
906
+ END IF;
907
+ IF v_receipt.processed_at IS NOT NULL AND v_receipt.processing_error IS NULL THEN
908
+ RETURN jsonb_build_object('duplicate', true, 'matched', true);
909
+ END IF;
910
+
911
+ SELECT * INTO v_job FROM public.plg_fiscalbr_nfce_jobs
912
+ WHERE provider_document_id = p_provider_document_id
913
+ ORDER BY created_at DESC LIMIT 1;
914
+ IF NOT FOUND THEN
915
+ UPDATE public.plg_fiscalbr_webhook_receipts
916
+ SET processed_at = NULL, processing_error = 'job_not_found'
917
+ WHERE id = v_receipt_id;
918
+ RETURN jsonb_build_object('duplicate', false, 'matched', false);
919
+ END IF;
920
+
921
+ UPDATE public.plg_fiscalbr_webhook_receipts
922
+ SET tenant_id = v_job.tenant_id, nota_id = v_job.nota_id, processed_at = now(), processing_error = NULL
923
+ WHERE id = v_receipt_id;
924
+ PERFORM public.plugnotas_apply_nfce_result(
925
+ v_job.tenant_id, v_job.id, p_status, p_provider_document_id,
926
+ coalesce(p_integration_id, v_job.integration_id), p_number, p_series,
927
+ p_access_key, p_message, p_payload
928
+ );
929
+ RETURN jsonb_build_object('duplicate', false, 'matched', true, 'jobId', v_job.id);
930
+ END $$;
931
+
932
+ CREATE OR REPLACE FUNCTION public.plugnotas_claim_nfce_recovery(
933
+ p_limit integer DEFAULT 20
934
+ ) RETURNS SETOF public.plg_fiscalbr_nfce_jobs
935
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
936
+ BEGIN
937
+ RETURN QUERY
938
+ WITH candidates AS (
939
+ SELECT j.id FROM public.plg_fiscalbr_nfce_jobs j
940
+ WHERE j.status IN ('queued', 'waiting', 'running')
941
+ AND coalesce(j.next_attempt_at, j.created_at) <= now()
942
+ AND coalesce(j.locked_until, '-infinity'::timestamptz) < now()
943
+ ORDER BY coalesce(j.next_attempt_at, j.created_at), j.created_at
944
+ FOR UPDATE SKIP LOCKED
945
+ LIMIT greatest(1, least(coalesce(p_limit, 20), 100))
946
+ )
947
+ UPDATE public.plg_fiscalbr_nfce_jobs j
948
+ SET status = 'running', locked_until = now() + interval '2 minutes', updated_at = now()
949
+ FROM candidates c WHERE j.id = c.id
950
+ RETURNING j.*;
951
+ END $$;
952
+ `;
953
+ var MIGRATIONS = [
954
+ { id: "000_baseline", sql: MIGRATION_000_BASELINE },
955
+ { id: "001_preserve_terminal_results_and_recover_leases", sql: MIGRATION_001_PRESERVE_TERMINAL_RESULTS_AND_RECOVER_LEASES }
956
+ ];
957
+
958
+ // src/lib/financial-provider.ts
959
+ function fiscalReader(provider) {
960
+ if (!provider.getNotaById) throw new Error("PlugNotas requires fiscal document reads from Financial.");
961
+ return provider.getNotaById.bind(provider);
962
+ }
963
+ async function committed(read, id) {
964
+ const document = await read(id);
965
+ if (!document) throw new Error("Fiscal document was not found after the server operation.");
966
+ return document;
967
+ }
968
+ function resultError(result, document, fallback) {
969
+ if (result.status !== "error") return;
970
+ throw new Error(result.message?.trim() || document.errorMessage?.trim() || fallback);
971
+ }
972
+ function cancellationPending(metadata) {
973
+ const provider = metadata?.plugnotas;
974
+ if (!provider || typeof provider !== "object" || Array.isArray(provider)) return false;
975
+ return provider.cancellationPending === true;
976
+ }
977
+ function artifactUrl(value) {
978
+ if (typeof value !== "string" || !value.trim()) throw new Error("PlugNotas returned an invalid artifact URL.");
979
+ let parsed;
980
+ try {
981
+ parsed = new URL(value);
982
+ } catch {
983
+ throw new Error("PlugNotas returned an invalid artifact URL.");
984
+ }
985
+ if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("PlugNotas returned an invalid artifact URL.");
986
+ return parsed.toString();
987
+ }
988
+ function createPlugNotasFinancialProvider(options) {
989
+ const { base, gateway } = options;
990
+ const issueNota = async (id) => {
991
+ const read = fiscalReader(base);
992
+ const document = await read(id);
993
+ if (!document) throw new Error("Fiscal document not found.");
994
+ if (document.model !== "nfce") throw new Error("PlugNotas currently authorizes NFC-e only.");
995
+ if (!document.orderId?.trim()) throw new Error("PlugNotas NFC-e must be created from an order.");
996
+ if (!["draft", "error"].includes(document.status)) return document;
997
+ const result = await gateway.issue({ notaId: id, idempotencyKey: `nfce:${id}` });
998
+ const saved = await committed(read, id);
999
+ resultError(result, saved, "PlugNotas could not issue this NFC-e.");
1000
+ return saved;
1001
+ };
1002
+ const refreshNota = async (id) => {
1003
+ const read = fiscalReader(base);
1004
+ const document = await read(id);
1005
+ if (!document) throw new Error("Fiscal document not found.");
1006
+ if (!["processing", "error"].includes(document.status) && !cancellationPending(document.metadata)) return document;
1007
+ const result = await gateway.refresh({ notaId: id });
1008
+ const saved = await committed(read, id);
1009
+ resultError(result, saved, "PlugNotas could not refresh this NFC-e.");
1010
+ return saved;
1011
+ };
1012
+ const cancelNota = async (id, reason) => {
1013
+ const read = fiscalReader(base);
1014
+ const document = await read(id);
1015
+ if (!document) throw new Error("Fiscal document not found.");
1016
+ if (document.status !== "issued") throw new Error("Only an issued NFC-e can be cancelled.");
1017
+ if (cancellationPending(document.metadata)) throw new Error("NFC-e cancellation is already in progress.");
1018
+ const normalized = reason?.trim() ?? "";
1019
+ if (normalized.length < 15 || normalized.length > 255) {
1020
+ throw new Error("Cancellation reason must contain between 15 and 255 characters.");
1021
+ }
1022
+ const result = await gateway.cancel({ notaId: id, reason: normalized });
1023
+ const saved = await committed(read, id);
1024
+ resultError(result, saved, "PlugNotas could not cancel this NFC-e.");
1025
+ return saved;
1026
+ };
1027
+ const getNotaArtifactUrl = async (id, kind) => {
1028
+ const read = fiscalReader(base);
1029
+ const document = await read(id);
1030
+ if (!document) throw new Error("Fiscal document not found.");
1031
+ if (!["issued", "cancelled"].includes(document.status)) {
1032
+ throw new Error("Fiscal artifacts are available only after authorization.");
1033
+ }
1034
+ if (kind === "cancellation_xml" && document.status !== "cancelled") {
1035
+ throw new Error("Cancellation XML is available only after cancellation.");
1036
+ }
1037
+ return artifactUrl((await gateway.artifact({ notaId: id, kind })).url);
1038
+ };
1039
+ return new Proxy(base, {
1040
+ get(target, property, receiver) {
1041
+ if (property === "issueNota") return issueNota;
1042
+ if (property === "refreshNota") return refreshNota;
1043
+ if (property === "cancelNota") return cancelNota;
1044
+ if (property === "getNotaArtifactUrl") return getNotaArtifactUrl;
1045
+ return Reflect.get(target, property, receiver);
1046
+ }
1047
+ });
1048
+ }
1049
+
1050
+ // src/index.ts
1051
+ function createPlugNotasPlugin(options = {}) {
1052
+ return definePlugin({
1053
+ id: "fiscal-br",
1054
+ name: "Fiscal Brasil",
1055
+ icon: "ReceiptText",
1056
+ version: "0.1.0",
1057
+ apiVersion: 1,
1058
+ scope: "addon",
1059
+ defaultEnabled: true,
1060
+ dependencies: ["financial"],
1061
+ navigation: [],
1062
+ routes: [],
1063
+ connectors: [createPlugNotasConnector(options)],
1064
+ functions: [{
1065
+ slug: "plugnotas-bridge",
1066
+ ingress: "none",
1067
+ verifyJwt: false,
1068
+ secrets: [
1069
+ "PLUGNOTAS_API_KEY",
1070
+ "PLUGNOTAS_BASE_URL",
1071
+ "PLUGNOTAS_ENVIRONMENT",
1072
+ "PLUGNOTAS_WEBHOOK_SECRET",
1073
+ "PLUGNOTAS_ARTIFACT_SIGNING_KEY",
1074
+ "PLUGNOTAS_ALLOWED_ORIGINS",
1075
+ "PLUGNOTAS_WORKER_SECRET",
1076
+ "SUPABASE_URL",
1077
+ "SUPABASE_ANON_KEY",
1078
+ "SUPABASE_SERVICE_ROLE_KEY"
1079
+ ],
1080
+ grants: [
1081
+ "plugnotas_register_emitter",
1082
+ "plugnotas_disconnect_emitter",
1083
+ "plugnotas_create_nfce_draft_for_order",
1084
+ "plugnotas_prepare_nfce",
1085
+ "plugnotas_prepare_nfce_cancel",
1086
+ "plugnotas_apply_nfce_result",
1087
+ "plugnotas_apply_nfce_webhook",
1088
+ "plugnotas_get_artifact_locator",
1089
+ "plugnotas_claim_nfce_recovery",
1090
+ "plugnotas_record_nfce_attempt"
1091
+ ]
1092
+ }],
1093
+ migrations: MIGRATIONS.map((entry) => ({
1094
+ id: `fiscal-br-${entry.id}`,
1095
+ version: "1.0.0",
1096
+ sql: entry.sql,
1097
+ description: "Durable PlugNotas delivery, recovery, webhook and emitter-registration boundary."
1098
+ })),
1099
+ diagnostics: [{
1100
+ id: "plugnotas-nfce-ready",
1101
+ description: "NFC-e requires the shared connection, order, item and financial fiscal-document contracts.",
1102
+ requires: {
1103
+ tables: [
1104
+ "connections",
1105
+ "orders",
1106
+ "items",
1107
+ "plg_financial_nfe",
1108
+ "plg_fiscalbr_nfce_jobs",
1109
+ "plg_fiscalbr_nfce_events",
1110
+ "plg_fiscalbr_webhook_receipts"
1111
+ ],
1112
+ rpcs: [
1113
+ "plugnotas_create_nfce_draft_for_order",
1114
+ "plugnotas_prepare_nfce",
1115
+ "plugnotas_apply_nfce_result",
1116
+ "plugnotas_apply_nfce_webhook",
1117
+ "plugnotas_claim_nfce_recovery"
1118
+ ],
1119
+ env: [
1120
+ "PLUGNOTAS_API_KEY",
1121
+ "PLUGNOTAS_BASE_URL",
1122
+ "PLUGNOTAS_ENVIRONMENT",
1123
+ "PLUGNOTAS_WEBHOOK_SECRET",
1124
+ "PLUGNOTAS_ARTIFACT_SIGNING_KEY",
1125
+ "PLUGNOTAS_ALLOWED_ORIGINS",
1126
+ "PLUGNOTAS_WORKER_SECRET"
1127
+ ]
1128
+ },
1129
+ level: "error"
1130
+ }]
1131
+ });
1132
+ }
1133
+
1134
+ export { PLUGNOTAS_CONNECTOR_ID, PLUGNOTAS_FIELDS, PLUGNOTAS_SETTINGS, createPlugNotasConnector, createPlugNotasFinancialProvider, createPlugNotasPlugin, isValidCnpj, markPlugNotasEmitterReady };
1135
+ //# sourceMappingURL=index.js.map
1136
+ //# sourceMappingURL=index.js.map