@fayz-ai/plugin-fiscal-br 0.4.1 → 0.5.1

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.
@@ -0,0 +1,60 @@
1
+ export type IssuedNoteModel = 'nfse' | 'nfce' | 'nfe';
2
+ export type IssuedNoteStatus = 'draft' | 'processing' | 'authorized' | 'rejected' | 'error' | 'cancelled' | 'substituted' | 'denied';
3
+ export interface IssuedNote {
4
+ documentId: string;
5
+ model: IssuedNoteModel;
6
+ status: IssuedNoteStatus;
7
+ environment: 'producao' | 'homologacao' | null;
8
+ number: string | null;
9
+ series: string | null;
10
+ accessKey: string | null;
11
+ verificationCode: string | null;
12
+ integrationId: string | null;
13
+ totalValue: number | null;
14
+ recipient: Record<string, unknown> | null;
15
+ rejection: unknown;
16
+ createdAt: string;
17
+ authorizedAt: string | null;
18
+ cancelledAt: string | null;
19
+ cancelReason: string | null;
20
+ }
21
+ export interface IssuedNoteEvent {
22
+ id: string;
23
+ eventType: string;
24
+ status: string | null;
25
+ justification: string | null;
26
+ createdAt: string;
27
+ }
28
+ export declare const MODEL_LABEL: Record<IssuedNoteModel, string>;
29
+ export type StatusTone = 'success' | 'warning' | 'danger' | 'muted';
30
+ export declare const STATUS_META: Record<IssuedNoteStatus, {
31
+ label: string;
32
+ tone: StatusTone;
33
+ }>;
34
+ export declare const EVENT_LABEL: Record<string, string>;
35
+ export declare function eventStatusLabel(status: string | null): string | null;
36
+ export declare function isWithoutEffect(status: IssuedNoteStatus): boolean;
37
+ /** A cancellation in progress sits in `processing` exactly like an issue in progress. */
38
+ export declare function statusLabel(note: Pick<IssuedNote, 'status' | 'cancelledAt'>): string;
39
+ export interface NotesSummary {
40
+ hasValid: boolean;
41
+ withoutEffect: number;
42
+ inFlight: number;
43
+ /** Only an AUTHORIZED test note misleads; a rejected one in homologação fools nobody. */
44
+ validIsTest: boolean;
45
+ }
46
+ export declare function summarizeNotes(notes: Pick<IssuedNote, 'status' | 'environment'>[]): NotesSummary;
47
+ export type NotesButtonState = 'none' | 'authorized' | 'processing' | 'failed-only';
48
+ export declare function notesButtonState(s: NotesSummary): NotesButtonState;
49
+ export interface RejectionField {
50
+ field: string;
51
+ label: string;
52
+ message: string;
53
+ }
54
+ /** PlugNotas rejection payload ({error:{message,data:{fields}}}) → something a person can act on. */
55
+ export declare function explainRejection(rejection: unknown): {
56
+ summary: string;
57
+ fields: RejectionField[];
58
+ };
59
+ export declare function recipientLabel(recipient: Record<string, unknown> | null): string;
60
+ //# sourceMappingURL=issued-note.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"issued-note.d.ts","sourceRoot":"","sources":["../../src/lib/issued-note.ts"],"names":[],"mappings":"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"}
@@ -16,6 +16,8 @@ export declare const MIGRATION_014_A_DESCRICAO_DA_NOTA_TEM_LIXO_NO_MEIO = "-- --
16
16
  export declare const MIGRATION_015_O_MOVIMENTO_DIZ_QUE_VEIO_DA_NOTA = "-- ---------------------------------------------------------------------------\n-- O movimento de estoque diz, por escrito, que veio desta nota.\n-- ---------------------------------------------------------------------------\n-- A aceita\u00E7\u00E3o j\u00E1 mandava a frase para `inventory_receive`, na chave `notes` do\n-- cabe\u00E7alho \u2014 e ela nunca chegava: l\u00E1 dentro o `coalesce(v_ln.notes, \u2026)` d\u00E1 a vez\n-- \u00E0 anota\u00E7\u00E3o DA LINHA, que aqui \u00E9 sempre a descri\u00E7\u00E3o do item. A tela de\n-- Movimenta\u00E7\u00F5es ent\u00E3o escrevia \u00ABSem motivo registrado\u00BB sobre a entrada mais\n-- rastre\u00E1vel que existe.\n--\n-- `inventory/021` abriu a chave `reason` no cabe\u00E7alho. Esta migration passa a\n-- us\u00E1-la, com a mesma frase que as linhas migradas do V1 carregam \u2014 para que a\n-- entrada de ontem e a de hoje se leiam igual no mesmo raz\u00E3o.\n--\n-- S\u00F3 a chamada muda. O resto do corpo \u00E9 o da 009, palavra por palavra: esta\n-- fun\u00E7\u00E3o escreve estoque e t\u00EDtulo na mesma transa\u00E7\u00E3o, e reescrev\u00EA-la de mem\u00F3ria\n-- \u00E9 como se perde uma regra.\n-- ---------------------------------------------------------------------------\n\nDO $m$\nDECLARE\n v_def text;\n v_antes text := E' ''ref'', ''NF-e '' || coalesce(v_doc.nfe_number, ''?''),\\n ''notes'', ''Entrada via DANFE NF-e '' || coalesce(v_doc.nfe_number, ''?'')),';\n v_depois text := E' ''ref'', ''NF-e '' || coalesce(v_doc.nfe_number, ''?''),\\n ''notes'', ''Entrada via DANFE NF-e '' || coalesce(v_doc.nfe_number, ''?''),\\n ''reason'', ''Entrada via DANFE NF-e '' || coalesce(v_doc.nfe_number, ''?'')),';\nBEGIN\n SELECT pg_get_functiondef(p.oid) INTO v_def\n FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'public' AND p.proname = 'fiscalbr_accept_inbox_document';\n\n IF v_def IS NULL THEN\n RAISE NOTICE 'fiscalbr: accept ainda n\u00E3o existe neste pool \u2014 009 roda antes e j\u00E1 traz o reason';\n RETURN;\n END IF;\n\n -- J\u00E1 aplicada: sair sem tocar em nada \u00E9 o que torna esta migration repet\u00EDvel.\n IF position('''reason'', ''Entrada via DANFE' in v_def) > 0 THEN RETURN; END IF;\n\n IF position(v_antes in v_def) = 0 THEN\n RAISE EXCEPTION 'fiscalbr/015: o corpo de fiscalbr_accept_inbox_document n\u00E3o \u00E9 o esperado \u2014 confira \u00E0 m\u00E3o em vez de deixar passar calado';\n END IF;\n\n EXECUTE replace(v_def, v_antes, v_depois);\nEND $m$;\n";
17
17
  export declare const MIGRATION_016_A_NOTA_GUARDA_O_PROPRIO_XML = "-- ---------------------------------------------------------------------------\n-- A nota guarda o pr\u00F3prio XML, em vez de l\u00EA-lo e jogar fora.\n-- ---------------------------------------------------------------------------\n-- O V1 abre o XML e o DANFE direto da lista de notas. A V2 mostrava os dois\n-- bot\u00F5es desligados porque n\u00E3o tinha o arquivo: das 889 notas deste cluster,\n-- ZERO t\u00EAm `document_id`, zero t\u00EAm bytes em `documents`, zero t\u00EAm nada no\n-- metadata.\n--\n-- E n\u00E3o \u00E9 que o arquivo nunca tenha passado por aqui \u2014 ele passa INTEIRO pela\n-- m\u00E3o do est\u00E1gio: o navegador l\u00EA o `.xml` que a pessoa arrastou, o parser extrai\n-- itens, parcelas e totais, e os bytes s\u00E3o descartados no fim da fun\u00E7\u00E3o. A nota\n-- fiscal \u00E9 documento com guarda legal de cinco anos, e a \u00FAnica c\u00F3pia ficava na\n-- pasta de downloads de quem importou.\n--\n-- `xml_content text` \u00E9 o mesmo nome e o mesmo tipo do `dfe_inbox.xml_content` do\n-- V1 \u2014 a ponte da 003 j\u00E1 citava essa coluna, e repetir o nome \u00E9 o que faz uma\n-- futura migra\u00E7\u00E3o de dados ser um `INSERT ... SELECT` em vez de um de-para.\n--\n-- Guardar no banco e n\u00E3o no bucket, de prop\u00F3sito: s\u00E3o 10\u201350 KB de texto por nota\n-- e s\u00E3o o \u00DANICO conte\u00FAdo que torna a linha audit\u00E1vel. Storage \u00E9 outro sistema,\n-- com outra vida \u00FAtil e outra pol\u00EDtica de acesso, e um XML fiscal que vive em\n-- bucket \u00E9 um XML que algu\u00E9m apaga limpando bucket.\n--\n-- Isto N\u00C3O preenche as 889 notas antigas: o que n\u00E3o foi guardado n\u00E3o se inventa.\n-- A tela desliga o bot\u00E3o por nota, n\u00E3o por feature.\n-- ---------------------------------------------------------------------------\n\nALTER TABLE public.plg_fiscalbr_inbox_documents\n ADD COLUMN IF NOT EXISTS xml_content text;\n\nCOMMENT ON COLUMN public.plg_fiscalbr_inbox_documents.xml_content IS\n 'O XML autorizado da NF-e, como chegou. Nulo nas notas importadas antes desta coluna existir \u2014 o que n\u00E3o foi guardado n\u00E3o se inventa.';\n\n-- \u2500\u2500 O est\u00E1gio passa a guardar o que j\u00E1 tinha em m\u00E3o \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- Patch sobre o corpo que est\u00E1 no cluster, e n\u00E3o reescrita: esta fun\u00E7\u00E3o insere\n-- documento, itens, duplicatas e pagamentos numa transa\u00E7\u00E3o s\u00F3, e reescrev\u00EA-la de\n-- mem\u00F3ria \u00E9 como se perde uma regra.\n\nDO $m$\nDECLARE\n v_def text;\n v_cols_antes text := E' total_freight, total_insurance, total_other, additional_info, metadata)';\n v_cols_depois text := E' total_freight, total_insurance, total_other, additional_info, metadata, xml_content)';\n v_vals_antes text := E' jsonb_build_object(''source'', coalesce(p_note->>''source'', ''xml_upload'')))';\n v_vals_depois text := E' jsonb_build_object(''source'', coalesce(p_note->>''source'', ''xml_upload'')),' || E'\\n' ||\n E' nullif(p_note->>''xml'', ''''))';\n v_upd_antes text := E' total_value = EXCLUDED.total_value,';\n v_upd_depois text := E' total_value = EXCLUDED.total_value,' || E'\\n' ||\n E' -- Reimportar com o arquivo preenche o que faltava; reimportar sem ele' || E'\\n' ||\n E' -- n\u00E3o apaga o que j\u00E1 estava guardado.' || E'\\n' ||\n E' xml_content = coalesce(EXCLUDED.xml_content, public.plg_fiscalbr_inbox_documents.xml_content),';\nBEGIN\n SELECT pg_get_functiondef(p.oid) INTO v_def\n FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'public' AND p.proname = 'fiscalbr_stage_inbox_document';\n\n IF v_def IS NULL THEN\n RAISE NOTICE 'fiscalbr/016: o est\u00E1gio ainda n\u00E3o existe neste pool \u2014 a 009 roda antes';\n RETURN;\n END IF;\n\n -- J\u00E1 aplicada: sair sem tocar em nada \u00E9 o que torna esta migration repet\u00EDvel.\n IF position('xml_content' in v_def) > 0 THEN RETURN; END IF;\n\n IF position(v_cols_antes in v_def) = 0\n OR position(v_vals_antes in v_def) = 0\n OR position(v_upd_antes in v_def) = 0 THEN\n RAISE EXCEPTION 'fiscalbr/016: o corpo de fiscalbr_stage_inbox_document n\u00E3o \u00E9 o esperado \u2014 confira \u00E0 m\u00E3o em vez de deixar passar calado';\n END IF;\n\n v_def := replace(v_def, v_cols_antes, v_cols_depois);\n v_def := replace(v_def, v_vals_antes, v_vals_depois);\n v_def := replace(v_def, v_upd_antes, v_upd_depois);\n EXECUTE v_def;\nEND $m$;\n";
18
18
  export declare const MIGRATION_017_QUEM_EMITE_E_O_CNPJ_NAO_A_LOJA = "-- Quem emite \u00E9 o CNPJ, n\u00E3o a loja.\n--\n-- As abas \"Certificado\" e \"Emiss\u00E3o Fiscal\" do cadastro de empresa do V1\n-- guardam, em companies, como aquela pessoa jur\u00EDdica emite NFS-e e NFC-e e se\n-- recebe DANFes da SEFAZ. No V2 a pessoa jur\u00EDdica \u00E9\n-- public.company_legal_entities (base), e a filial chega nela por\n-- app.units.company_legal_entity_id (168). Esta tabela \u00E9 uma linha por pessoa\n-- jur\u00EDdica:\n--\n-- unidade \u2192 app.units.company_legal_entity_id \u2192 company_legal_entities.id\n-- \u2192 plg_fiscalbr_emitter_settings.legal_entity_id (UNIQUE)\n--\n-- Duas filiais com o mesmo CNPJ dividem a mesma configura\u00E7\u00E3o, como no V1.\n--\n-- O certificado A1 N\u00C3O mora aqui. O .pfx e a senha v\u00E3o direto ao provedor\n-- (PlugNotas) e o que fica \u00E9 o recibo: id no provedor, titular e validade. O\n-- CSC da NFC-e \u00E9 segredo \u2014 o navegador grava, mas n\u00E3o l\u00EA de volta: a coluna\n-- fica fora do GRANT SELECT e a tela v\u00EA s\u00F3 `nfce_csc_token_set`.\n--\n-- Plano aprovado em docs/port/empresa.schema.json. Idempotente.\n\nCREATE TABLE IF NOT EXISTS public.plg_fiscalbr_emitter_settings (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n legal_entity_id uuid NOT NULL,\n\n nfse_enabled boolean NOT NULL DEFAULT false,\n nfse_production boolean NOT NULL DEFAULT false,\n nfse_national boolean NOT NULL DEFAULT false,\n nfse_service_code_national text,\n nfse_service_code_municipal text,\n nfse_nbs_code text,\n nfse_cnae text,\n nfse_iss_rate numeric(5,2) CHECK (nfse_iss_rate IS NULL OR (nfse_iss_rate >= 0 AND nfse_iss_rate <= 100)),\n nfse_iss_taxation smallint,\n nfse_iss_exigibility smallint,\n nfse_ibscbs_cst text,\n nfse_ibscbs_classification text,\n nfse_simples_regime smallint,\n nfse_net_value boolean NOT NULL DEFAULT false,\n nfse_additional_info text,\n nfse_city_features jsonb,\n\n nfce_enabled boolean NOT NULL DEFAULT false,\n nfce_production boolean NOT NULL DEFAULT false,\n nfce_series integer CHECK (nfce_series IS NULL OR nfce_series > 0),\n nfce_csc_id text,\n nfce_csc_token text,\n nfce_csc_token_set boolean GENERATED ALWAYS AS (nfce_csc_token IS NOT NULL AND nfce_csc_token <> '') STORED,\n nfce_generic_item jsonb,\n\n nfe_enabled boolean NOT NULL DEFAULT false,\n\n certificate_provider_id text,\n certificate_cn text,\n certificate_expires_at timestamptz,\n certificate_uploaded_at timestamptz,\n\n dfe_sync_enabled boolean NOT NULL DEFAULT false,\n dfe_sync_cursor text,\n dfe_last_sync_at timestamptz,\n provider_synced_at timestamptz,\n\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n UNIQUE (tenant_id, id)\n);\n\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_fiscalbr_emitter_settings_legal_entity_fkey') THEN\n -- company_legal_entities \u00E9 da base, n\u00E3o de outro plugin: a FK f\u00EDsica \u00E9\n -- permitida (a regra do 000 pro\u00EDbe FK entre PLUGINS).\n ALTER TABLE ONLY public.plg_fiscalbr_emitter_settings\n ADD CONSTRAINT plg_fiscalbr_emitter_settings_legal_entity_fkey\n FOREIGN KEY (legal_entity_id) REFERENCES public.company_legal_entities(id) ON DELETE CASCADE;\n END IF;\nEND $$;\n\nCREATE UNIQUE INDEX IF NOT EXISTS plg_fiscalbr_emitter_settings_legal_entity_uidx\n ON public.plg_fiscalbr_emitter_settings USING btree (legal_entity_id);\n\nCOMMENT ON TABLE public.plg_fiscalbr_emitter_settings IS\n 'V1: companies (abas Certificado + Emiss\u00E3o Fiscal). Como uma pessoa jur\u00EDdica emite NFS-e/NFC-e e recebe DANFes. Uma linha por CNPJ; a filial chega por app.units.company_legal_entity_id.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.legal_entity_id IS 'A chave que conecta: company_legal_entities.id. UNIQUE.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_production IS 'V1: companies.nfse_producao \u2014 false = homologa\u00E7\u00E3o.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_national IS 'V1: companies.nfse_nacional \u2014 padr\u00E3o nacional \u00D7 municipal.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_service_code_national IS 'V1: companies.nfse_codigo_tributacao_nacional';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_service_code_municipal IS 'V1: companies.nfse_codigo_tributacao_municipio';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_nbs_code IS 'V1: companies.nfse_codigo_nbs';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_cnae IS 'V1: companies.nfse_cnae \u2014 CNAE do servi\u00E7o, pode diferir do principal.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_iss_rate IS 'V1: companies.nfse_iss_aliquota (%)';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_iss_taxation IS 'V1: companies.nfse_iss_tipo_tributacao';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_iss_exigibility IS 'V1: companies.nfse_iss_exigibilidade';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_simples_regime IS 'V1: companies.nfse_regime_apuracao_sn \u2014 s\u00F3 para optante do Simples.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_net_value IS 'V1: companies.nfse_valor_liquido \u2014 emitir pelo valor l\u00EDquido.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_city_features IS 'V1: companies.nfse_city_features \u2014 o que a prefeitura aceita, devolvido pelo provedor.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfce_csc_token IS 'V1: companies.nfce_csc_token \u2014 SEGREDO. Fora do GRANT SELECT de authenticated.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfce_generic_item IS 'V1: companies.nfce_item_generico \u2014 {ncm, cfop, csosn}.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.certificate_provider_id IS 'V1: companies.certificate_plugnotas_id \u2014 recibo; o .pfx n\u00E3o fica no banco.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.dfe_sync_enabled IS 'V1: companies.dfe_sync_enabled \u2014 receber DANFes automaticamente.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.dfe_sync_cursor IS 'V1: companies.dfe_sync_cursor \u2014 NSU onde a busca na SEFAZ parou.';\n\nALTER TABLE public.plg_fiscalbr_emitter_settings ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_fiscalbr_emitter_settings FORCE ROW LEVEL SECURITY;\n\n-- O segredo fica fora do SELECT: grava-se, n\u00E3o se l\u00EA.\nREVOKE ALL ON TABLE public.plg_fiscalbr_emitter_settings FROM anon, authenticated;\nGRANT INSERT, UPDATE, DELETE ON TABLE public.plg_fiscalbr_emitter_settings TO authenticated;\nGRANT SELECT (\n id, tenant_id, legal_entity_id,\n nfse_enabled, nfse_production, nfse_national, nfse_service_code_national, nfse_service_code_municipal,\n nfse_nbs_code, nfse_cnae, nfse_iss_rate, nfse_iss_taxation, nfse_iss_exigibility, nfse_ibscbs_cst,\n nfse_ibscbs_classification, nfse_simples_regime, nfse_net_value, nfse_additional_info, nfse_city_features,\n nfce_enabled, nfce_production, nfce_series, nfce_csc_id, nfce_csc_token_set, nfce_generic_item,\n nfe_enabled, certificate_provider_id, certificate_cn, certificate_expires_at, certificate_uploaded_at,\n dfe_sync_enabled, dfe_sync_cursor, dfe_last_sync_at, provider_synced_at, created_at, updated_at\n) ON public.plg_fiscalbr_emitter_settings TO authenticated;\nGRANT ALL ON TABLE public.plg_fiscalbr_emitter_settings TO service_role;\n\nDROP POLICY IF EXISTS plg_fiscalbr_emitter_settings_authz_select ON public.plg_fiscalbr_emitter_settings;\nCREATE POLICY plg_fiscalbr_emitter_settings_authz_select ON public.plg_fiscalbr_emitter_settings\n FOR SELECT TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.read', NULL::uuid)));\n\nDROP POLICY IF EXISTS plg_fiscalbr_emitter_settings_authz_write ON public.plg_fiscalbr_emitter_settings;\nCREATE POLICY plg_fiscalbr_emitter_settings_authz_write ON public.plg_fiscalbr_emitter_settings\n FOR ALL TO authenticated\n USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('settings.manage', NULL::uuid)))\n WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('settings.manage', NULL::uuid))\n AND EXISTS (SELECT 1 FROM public.company_legal_entities le\n WHERE le.id = plg_fiscalbr_emitter_settings.legal_entity_id\n AND le.tenant_id = plg_fiscalbr_emitter_settings.tenant_id));\n";
19
+ export declare const MIGRATION_018_A_NOTA_DO_V1_ALCANCA_A_CONTA = "-- A nota que veio do V1 alcan\u00E7a a conta que a gerou.\n--\n-- A conta a receber pergunta \"que notas eu tenho?\" pelo pedido dela:\n-- plg_financial_invoices.order_id \u2192 plg_fiscalbr_document_orders \u2192 a nota.\n-- A importa\u00E7\u00E3o s\u00F3 preencheu esse v\u00EDnculo a partir da tabela N:N do V1\n-- (fiscal_document_invoice_sources). A NFS-e do V1 nunca usou essa tabela: ela\n-- aponta a conta por fiscal_documents.invoice_id, que chegou aqui apenas como\n-- metadata.v1.invoice_ref. Medido em 06-10-2026 no cluster do ChefControl: 488\n-- NFS-e e 122 NFC-e sem caminho at\u00E9 a conta.\n--\n-- A conta importada guarda o pr\u00F3prio id do V1 em\n-- metadata.migration.source_numeric_id, ent\u00E3o o par se resolve sem adivinhar.\n-- Num pool sem dado do V1 isto n\u00E3o casa nada. Aditivo e idempotente.\n\nINSERT INTO public.plg_fiscalbr_document_orders (tenant_id, document_id, order_id)\nSELECT DISTINCT f.tenant_id, f.document_id, i.order_id\n FROM public.plg_fiscalbr_documents f\n JOIN public.plg_financial_invoices i\n ON i.tenant_id = f.tenant_id\n AND i.metadata -> 'migration' ->> 'source_table' = 'invoices'\n AND i.metadata -> 'migration' ->> 'source_numeric_id' = f.metadata -> 'v1' ->> 'invoice_ref'\n JOIN public.orders o ON o.id = i.order_id\n WHERE f.metadata -> 'v1' ->> 'invoice_ref' IS NOT NULL\nON CONFLICT (document_id, order_id) DO NOTHING;\n";
20
+ export declare const MIGRATION_019_A_NOTA_EMITIDA_SE_CANCELA_PELO_V2 = "-- A nota emitida se consulta e se cancela pelo V2.\n--\n-- Quem fala com a PlugNotas \u00E9 a fun\u00E7\u00E3o `fiscalbr-plugnotas` (a chave s\u00F3 existe\n-- no servidor), mas ela roda com o JWT de quem clicou e n\u00E3o grava nada direto:\n-- pergunta aqui se a pessoa pode, e devolve o resultado aqui. Uma porta de\n-- leitura, uma de escrita, a permiss\u00E3o conferida nas duas.\n--\n-- consultar e baixar o PDF \u2192 financial.read na unidade da nota\n-- cancelar \u2192 financial.edit na unidade da nota\n--\n-- A espelho em `documents.status` anda junto: as duas tabelas s\u00E3o a mesma nota\n-- vista de dois lugares (005), e um status divergente entre elas \u00E9 a nota\n-- dizendo duas coisas. Idempotente.\n\nCREATE OR REPLACE FUNCTION public.fiscalbr_note_for_provider(p_document_id uuid, p_action text)\nRETURNS jsonb\nLANGUAGE plpgsql\nSTABLE SECURITY DEFINER\nSET search_path TO ''\nAS $$\nDECLARE\n v public.plg_fiscalbr_documents%ROWTYPE;\nBEGIN\n SELECT * INTO v FROM public.plg_fiscalbr_documents\n WHERE document_id = p_document_id AND tenant_id = app.current_tenant_id();\n IF NOT FOUND THEN\n RAISE EXCEPTION 'fiscal: note not found' USING ERRCODE = 'P0002';\n END IF;\n PERFORM public.plg_financial_require_permission(\n CASE WHEN p_action = 'cancel' THEN 'financial.edit' ELSE 'financial.read' END, v.unit_id);\n RETURN jsonb_build_object(\n 'document_id', v.document_id, 'model', v.model, 'status', v.status,\n 'environment', v.environment, 'provider_document_id', v.provider_document_id,\n 'cancelled_at', v.cancelled_at, 'access_key', v.access_key, 'number', v.number);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.fiscalbr_note_apply(\n p_document_id uuid,\n p_action text,\n p_patch jsonb,\n p_event jsonb DEFAULT NULL\n)\nRETURNS void\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path TO ''\nAS $$\nDECLARE\n v public.plg_fiscalbr_documents%ROWTYPE;\n v_status text := p_patch ->> 'status';\nBEGIN\n SELECT * INTO v FROM public.plg_fiscalbr_documents\n WHERE document_id = p_document_id AND tenant_id = app.current_tenant_id()\n FOR UPDATE;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'fiscal: note not found' USING ERRCODE = 'P0002';\n END IF;\n PERFORM public.plg_financial_require_permission(\n CASE WHEN p_action = 'cancel' THEN 'financial.edit' ELSE 'financial.read' END, v.unit_id);\n IF v_status IS NOT NULL AND v_status NOT IN ('processing','authorized','rejected','error','cancelled','substituted') THEN\n RAISE EXCEPTION 'fiscal: unknown status %', v_status USING ERRCODE = '22023';\n END IF;\n\n -- Only what the provider answers; identity (model, unit, order) never moves.\n UPDATE public.plg_fiscalbr_documents SET\n status = COALESCE(v_status, status),\n number = COALESCE(p_patch ->> 'number', number),\n series = COALESCE(p_patch ->> 'series', series),\n access_key = COALESCE(p_patch ->> 'access_key', access_key),\n verification_code = COALESCE(p_patch ->> 'verification_code', verification_code),\n status_payload = COALESCE(p_patch -> 'status_payload', status_payload),\n rejection_payload = CASE WHEN p_patch ? 'rejection_payload' THEN p_patch -> 'rejection_payload' ELSE rejection_payload END,\n authorized_at = COALESCE((p_patch ->> 'authorized_at')::timestamptz, authorized_at),\n last_polled_at = CASE WHEN p_action = 'poll' THEN now() ELSE last_polled_at END,\n cancelled_at = CASE WHEN p_patch ? 'cancelled_at' THEN (p_patch ->> 'cancelled_at')::timestamptz ELSE cancelled_at END,\n cancel_reason = CASE WHEN p_patch ? 'cancel_reason' THEN p_patch ->> 'cancel_reason' ELSE cancel_reason END,\n cancelled_by_user_id = CASE WHEN p_action = 'cancel' THEN auth.uid()\n WHEN p_patch ? 'cancelled_at' AND p_patch -> 'cancelled_at' = 'null'::jsonb THEN NULL\n ELSE cancelled_by_user_id END\n WHERE document_id = p_document_id;\n\n IF v_status IS NOT NULL THEN\n UPDATE public.documents SET status = v_status WHERE id = p_document_id AND status IS DISTINCT FROM v_status;\n END IF;\n\n IF p_event IS NOT NULL THEN\n INSERT INTO public.plg_fiscalbr_document_events\n (tenant_id, unit_id, document_id, event_type, status, justification, response_payload, created_by_user_id)\n VALUES (v.tenant_id, v.unit_id, p_document_id, p_event ->> 'event_type', p_event ->> 'status',\n p_event ->> 'justification', p_event -> 'response_payload', auth.uid());\n END IF;\nEND $$;\n\nREVOKE ALL ON FUNCTION public.fiscalbr_note_for_provider(uuid, text) FROM PUBLIC, anon;\nREVOKE ALL ON FUNCTION public.fiscalbr_note_apply(uuid, text, jsonb, jsonb) FROM PUBLIC, anon;\nGRANT EXECUTE ON FUNCTION public.fiscalbr_note_for_provider(uuid, text) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.fiscalbr_note_apply(uuid, text, jsonb, jsonb) TO authenticated;\n";
19
21
  export declare const MIGRATIONS: Array<{
20
22
  id: string;
21
23
  sql: string;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,sBAAsB,2g0BA2jBlC,CAAA;AAED,eAAO,MAAM,0DAA0D,uzRAmMtE,CAAA;AAED,eAAO,MAAM,+CAA+C,m5oBAyW3D,CAAA;AAED,eAAO,MAAM,0CAA0C,o1XAgPtD,CAAA;AAED,eAAO,MAAM,8CAA8C,6qCAsB1D,CAAA;AAED,eAAO,MAAM,2CAA2C,w8dA6MvD,CAAA;AAED,eAAO,MAAM,yCAAyC,42BAkBrD,CAAA;AAED,eAAO,MAAM,oDAAoD,4oDA+BhE,CAAA;AAED,eAAO,MAAM,yDAAyD,otbA0NrE,CAAA;AAED,eAAO,MAAM,gEAAgE,q1+BAoiB5E,CAAA;AAED,eAAO,MAAM,gEAAgE,mqVAgO5E,CAAA;AAED,eAAO,MAAM,8CAA8C,k/HAiF1D,CAAA;AAED,eAAO,MAAM,oDAAoD,w2OA4JhE,CAAA;AAED,eAAO,MAAM,oDAAoD,gvRAwJhE,CAAA;AAED,eAAO,MAAM,kDAAkD,shPAoK9D,CAAA;AAED,eAAO,MAAM,8CAA8C,8/EA0C1D,CAAA;AAED,eAAO,MAAM,yCAAyC,2mJA2ErD,CAAA;AAED,eAAO,MAAM,4CAA4C,68QA0IxD,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAmBzD,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,sBAAsB,2g0BA2jBlC,CAAA;AAED,eAAO,MAAM,0DAA0D,uzRAmMtE,CAAA;AAED,eAAO,MAAM,+CAA+C,m5oBAyW3D,CAAA;AAED,eAAO,MAAM,0CAA0C,o1XAgPtD,CAAA;AAED,eAAO,MAAM,8CAA8C,6qCAsB1D,CAAA;AAED,eAAO,MAAM,2CAA2C,w8dA6MvD,CAAA;AAED,eAAO,MAAM,yCAAyC,42BAkBrD,CAAA;AAED,eAAO,MAAM,oDAAoD,4oDA+BhE,CAAA;AAED,eAAO,MAAM,yDAAyD,otbA0NrE,CAAA;AAED,eAAO,MAAM,gEAAgE,q1+BAoiB5E,CAAA;AAED,eAAO,MAAM,gEAAgE,mqVAgO5E,CAAA;AAED,eAAO,MAAM,8CAA8C,k/HAiF1D,CAAA;AAED,eAAO,MAAM,oDAAoD,w2OA4JhE,CAAA;AAED,eAAO,MAAM,oDAAoD,gvRAwJhE,CAAA;AAED,eAAO,MAAM,kDAAkD,shPAoK9D,CAAA;AAED,eAAO,MAAM,8CAA8C,8/EA0C1D,CAAA;AAED,eAAO,MAAM,yCAAyC,2mJA2ErD,CAAA;AAED,eAAO,MAAM,4CAA4C,68QA0IxD,CAAA;AAED,eAAO,MAAM,0CAA0C,83CAwBtD,CAAA;AAED,eAAO,MAAM,+CAA+C,81JAiG3D,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAqBzD,CAAA"}
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ export interface InvoiceNotesButtonProps {
3
+ invoiceId?: string;
4
+ direction?: string;
5
+ cancelled?: boolean;
6
+ }
7
+ /** Header action of a receivable: the notes issued for it, as the V1's "Nota Fiscal" button. */
8
+ export declare function InvoiceNotesButton({ invoiceId, direction, cancelled }: InvoiceNotesButtonProps): React.JSX.Element | null;
9
+ //# sourceMappingURL=InvoiceNotesButton.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"InvoiceNotesButton.d.ts","sourceRoot":"","sources":["../../src/views/InvoiceNotesButton.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA2C,MAAM,OAAO,CAAA;AAmC/D,MAAM,WAAW,uBAAuB;IACtC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB;AAED,gGAAgG;AAChG,wBAAgB,kBAAkB,CAAC,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,EAAE,uBAAuB,4BAoE9F"}
@@ -0,0 +1,222 @@
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
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fayz-ai/plugin-fiscal-br",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "[preview] Brazilian fiscal addon for asynchronous NFC-e emission through PlugNotas",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -24,10 +24,10 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "@tanstack/react-table": "^8.20.0",
27
- "@fayz-ai/admin": "^0.37.0",
28
- "@fayz-ai/core": "^0.36.1",
29
- "@fayz-ai/plugin-financial": "^0.20.0",
30
- "@fayz-ai/ui": "^0.37.0"
27
+ "@fayz-ai/admin": "^0.39.0",
28
+ "@fayz-ai/core": "^0.39.0",
29
+ "@fayz-ai/plugin-financial": "^0.21.1",
30
+ "@fayz-ai/ui": "^0.39.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^20.0.0",