@fayz-ai/plugin-fiscal-br 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/data/issued.d.ts +2 -2
- package/dist/data/issued.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +630 -71
- package/dist/index.js.map +1 -1
- package/dist/lib/emitters.d.ts +2 -8
- package/dist/lib/emitters.d.ts.map +1 -1
- package/dist/lib/notes.d.ts +42 -0
- package/dist/lib/notes.d.ts.map +1 -0
- package/dist/lib/service.d.ts +20 -0
- package/dist/lib/service.d.ts.map +1 -0
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/views/OrderNoteCard.d.ts +5 -0
- package/dist/views/OrderNoteCard.d.ts.map +1 -0
- package/package.json +3 -3
- package/functions/fiscalbr-certificate/index.ts +0 -162
- package/functions/fiscalbr-plugnotas/index.ts +0 -222
package/dist/lib/emitters.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { EmitterError } from './service';
|
|
2
|
+
export { EmitterError };
|
|
1
3
|
export type EmitterMode = 'observe' | 'own';
|
|
2
4
|
export interface EmitterModel {
|
|
3
5
|
enabled: boolean;
|
|
@@ -65,14 +67,6 @@ export interface EmitterSettingsInput {
|
|
|
65
67
|
cscToken?: string | null;
|
|
66
68
|
};
|
|
67
69
|
}
|
|
68
|
-
/** A refusal from the service: `code` picks the sentence, `gates` lists what is missing. */
|
|
69
|
-
export declare class EmitterError extends Error {
|
|
70
|
-
readonly status: number;
|
|
71
|
-
readonly code: string;
|
|
72
|
-
readonly gates: EmitterGate[];
|
|
73
|
-
readonly field: string | null;
|
|
74
|
-
constructor(status: number, code: string, message: string, gates?: EmitterGate[], field?: string | null);
|
|
75
|
-
}
|
|
76
70
|
export declare function emitterErrorSentence(error: unknown): string;
|
|
77
71
|
/** What each gate asks of the store, in its words. */
|
|
78
72
|
export declare const GATE_LABELS: Record<string, string>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"emitters.d.ts","sourceRoot":"","sources":["../../src/lib/emitters.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"emitters.d.ts","sourceRoot":"","sources":["../../src/lib/emitters.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,YAAY,EAAW,MAAM,WAAW,CAAA;AAEjD,OAAO,EAAE,YAAY,EAAE,CAAA;AAEvB,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,KAAK,CAAA;AAE3C,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,OAAO,CAAA;IAChB,UAAU,EAAE,OAAO,CAAA;IACnB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAC1B;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,EAAE,EAAE,OAAO,CAAA;IACX,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB;AAED,MAAM,WAAW,OAAO;IACtB,aAAa,EAAE,MAAM,CAAA;IACrB,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,yDAAyD;IACzD,IAAI,EAAE,WAAW,GAAG,IAAI,CAAA;IACxB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,oBAAoB,EAAE,MAAM,CAAA;IAC5B,GAAG,EAAE,YAAY,CAAA;IACjB,IAAI,EAAE,YAAY,GAAG;QAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,WAAW,EAAE,OAAO,CAAA;KAAE,CAAA;IACnE,WAAW,EAAE;QAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,OAAO,EAAE,SAAS,GAAG,UAAU,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI,CAAA;IAC1I,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,QAAQ,CAAC,EAAE;QACT,UAAU,EAAE,OAAO,CAAA;QACnB,WAAW,EAAE;YAAE,EAAE,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;YAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;SAAE,GAAG,IAAI,CAAA;QACnF,IAAI,EAAE;YAAE,MAAM,EAAE,OAAO,CAAC;YAAC,UAAU,EAAE,OAAO,CAAC;YAAC,MAAM,EAAE,MAAM,EAAE,CAAA;SAAE,GAAG,IAAI,CAAA;QACvE,GAAG,EAAE;YAAE,MAAM,EAAE,OAAO,CAAC;YAAC,GAAG,EAAE,OAAO,CAAA;SAAE,GAAG,IAAI,CAAA;QAC7C,OAAO,EAAE;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,GAAG,IAAI,CAAA;KAChC,CAAA;IACD,KAAK,CAAC,EAAE,WAAW,EAAE,CAAA;IACrB,KAAK,CAAC,EAAE,OAAO,CAAA;CAChB;AAED,MAAM,WAAW,oBAAoB;IACnC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,oBAAoB,CAAC,EAAE,MAAM,CAAA;IAC7B,GAAG,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,YAAY,EAAE,SAAS,GAAG,YAAY,GAAG,QAAQ,GAAG,YAAY,CAAC,CAAC,CAAA;IACrF,IAAI,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,YAAY,EAAE,SAAS,GAAG,YAAY,GAAG,QAAQ,GAAG,YAAY,CAAC,CAAC,GAAG;QAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAA;CAC7I;AAmCD,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAM3D;AAED,sDAAsD;AACtD,eAAO,MAAM,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAe9C,CAAA;AAYD,eAAO,MAAM,QAAQ;;IAEnB,6EAA6E;0BACvD,MAAM;0BACN,MAAM,YAAY,oBAAoB;iCACzB,MAAM,QAAQ,IAAI,YAAY,MAAM;8BAE7C,MAAM;CACjC,CAAA;AAED,eAAO,MAAM,WAAW;;;;;;;;;EAMd,CAAA;AAEV,8EAA8E;AAC9E,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAatB,CAAA"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type NoteStatus = 'processing' | 'authorized' | 'rejected' | 'error' | 'cancelled' | 'substituted';
|
|
2
|
+
export interface OrderNote {
|
|
3
|
+
documentId: string;
|
|
4
|
+
orderId: string | null;
|
|
5
|
+
model: 'nfce' | 'nfe';
|
|
6
|
+
status: NoteStatus;
|
|
7
|
+
environment: string | null;
|
|
8
|
+
series: string | null;
|
|
9
|
+
number: string | null;
|
|
10
|
+
accessKey: string | null;
|
|
11
|
+
protocol: string | null;
|
|
12
|
+
total: number | null;
|
|
13
|
+
rejection: {
|
|
14
|
+
message?: string | null;
|
|
15
|
+
fields?: Array<{
|
|
16
|
+
field: string;
|
|
17
|
+
message: string;
|
|
18
|
+
}>;
|
|
19
|
+
raw?: unknown;
|
|
20
|
+
} | null;
|
|
21
|
+
authorizedAt: string | null;
|
|
22
|
+
cancelledAt: string | null;
|
|
23
|
+
cancelReason: string | null;
|
|
24
|
+
/** processing while a cancellation waits on the SEFAZ. */
|
|
25
|
+
cancellation: string | null;
|
|
26
|
+
/** The send got no answer; the service is looking for the note by its id. */
|
|
27
|
+
uncertain: boolean;
|
|
28
|
+
createdAt: string;
|
|
29
|
+
}
|
|
30
|
+
export declare const CANCEL_MIN_JUSTIFICATION = 15;
|
|
31
|
+
export declare function noteErrorSentence(error: unknown): string;
|
|
32
|
+
export declare const orderNotes: {
|
|
33
|
+
list: (orderId: string) => Promise<OrderNote[]>;
|
|
34
|
+
issueNfce: (orderId: string) => Promise<OrderNote>;
|
|
35
|
+
/** Reads PlugNotas again while the note is processing. */
|
|
36
|
+
read: (documentId: string) => Promise<OrderNote>;
|
|
37
|
+
cancel: (documentId: string, justification: string) => Promise<OrderNote>;
|
|
38
|
+
pdf: (documentId: string) => Promise<Blob>;
|
|
39
|
+
xml: (documentId: string) => Promise<Blob>;
|
|
40
|
+
};
|
|
41
|
+
export declare const NOTE_STATUS_LABEL: Record<NoteStatus, string>;
|
|
42
|
+
//# sourceMappingURL=notes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notes.d.ts","sourceRoot":"","sources":["../../src/lib/notes.ts"],"names":[],"mappings":"AAMA,MAAM,MAAM,UAAU,GAAG,YAAY,GAAG,YAAY,GAAG,UAAU,GAAG,OAAO,GAAG,WAAW,GAAG,aAAa,CAAA;AAEzG,MAAM,WAAW,SAAS;IACxB,UAAU,EAAE,MAAM,CAAA;IAClB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,KAAK,EAAE,MAAM,GAAG,KAAK,CAAA;IACrB,MAAM,EAAE,UAAU,CAAA;IAClB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,SAAS,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,MAAM,CAAC,EAAE,KAAK,CAAC;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;QAAC,GAAG,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,IAAI,CAAA;IAChH,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,0DAA0D;IAC1D,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,6EAA6E;IAC7E,SAAS,EAAE,OAAO,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,eAAO,MAAM,wBAAwB,KAAK,CAAA;AA+B1C,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CASxD;AAKD,eAAO,MAAM,UAAU;oBACC,MAAM;yBACP,MAAM;IAC3B,0DAA0D;uBACvC,MAAM;yBACJ,MAAM,iBAAiB,MAAM;sBAChC,MAAM;sBACN,MAAM;CACzB,CAAA;AAED,eAAO,MAAM,iBAAiB,EAAE,MAAM,CAAC,UAAU,EAAE,MAAM,CAOxD,CAAA"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** One check the service ran, as it answers it with a refusal. */
|
|
2
|
+
export interface ServiceGate {
|
|
3
|
+
id: string;
|
|
4
|
+
ok: boolean;
|
|
5
|
+
detail: string | null;
|
|
6
|
+
}
|
|
7
|
+
/** A refusal from the service: `code` picks the sentence, `gates` lists what is missing. */
|
|
8
|
+
export declare class EmitterError extends Error {
|
|
9
|
+
readonly status: number;
|
|
10
|
+
readonly code: string;
|
|
11
|
+
readonly gates: ServiceGate[];
|
|
12
|
+
readonly field: string | null;
|
|
13
|
+
constructor(status: number, code: string, message: string, gates?: ServiceGate[], field?: string | null);
|
|
14
|
+
}
|
|
15
|
+
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT';
|
|
16
|
+
export declare function request<T>(method: Method, path: string, body?: unknown): Promise<T>;
|
|
17
|
+
/** A file the service streams (a note's PDF or XML). */
|
|
18
|
+
export declare function requestFile(path: string): Promise<Blob>;
|
|
19
|
+
export {};
|
|
20
|
+
//# sourceMappingURL=service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/lib/service.ts"],"names":[],"mappings":"AAMA,kEAAkE;AAClE,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,EAAE,EAAE,OAAO,CAAA;IACX,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB;AAED,4FAA4F;AAC5F,qBAAa,YAAa,SAAQ,KAAK;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM;IAAmB,QAAQ,CAAC,KAAK,EAAE,WAAW,EAAE;IAAO,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;gBAAzH,MAAM,EAAE,MAAM,EAAW,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAW,KAAK,GAAE,WAAW,EAAO,EAAW,KAAK,GAAE,MAAM,GAAG,IAAW;CAItJ;AAGD,KAAK,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG,OAAO,GAAG,KAAK,CAAA;AAuC9C,wBAAsB,OAAO,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC,CAKzF;AAED,wDAAwD;AACxD,wBAAsB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAI7D"}
|
|
@@ -29,6 +29,7 @@ export declare const MIGRATION_027_A_NOTA_COM_CONTA_E_SEM_ESTOQUE_TERMINA_NO_V2
|
|
|
29
29
|
export declare const MIGRATION_028_A_LINHA_DE_ESTOQUE_TEM_UNIDADE = "-- ---------------------------------------------------------------------------\n-- A stock line enters with its unit.\n-- ---------------------------------------------------------------------------\n-- Accepting let a stock line through with no stock unit; the movement then\n-- carried a bare quantity that every screen reads as \"unidade\" (Artorius, NF-e\n-- 222879: 20 kg of copa lombo entered as 20 units). The screen now suggests the\n-- unit and refuses without one; this is the same rule where it cannot be\n-- skipped.\n-- ---------------------------------------------------------------------------\n\nDO $m$\nDECLARE\n v_def text;\n v_old constant text := $q$ IF v_it.product_id IS NULL THEN$q$;\n v_new constant text := $q$ IF v_it.stock_unit_id IS NULL THEN\n RAISE EXCEPTION 'fiscalbr: item % entra no estoque sem unidade \u2014 escolha a unidade de estoque', v_it.item_number\n USING ERRCODE = '23514';\n END IF;\n IF v_it.product_id IS NULL THEN$q$;\nBEGIN\n SELECT pg_get_functiondef(p.oid) INTO v_def 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 IF v_def IS NULL THEN RAISE EXCEPTION 'fiscalbr/028: fiscalbr_accept_inbox_document does not exist'; END IF;\n IF position('entra no estoque sem unidade' in v_def) > 0 THEN RETURN; END IF;\n IF position(v_old in v_def) = 0 THEN\n RAISE EXCEPTION 'fiscalbr/028: unexpected body of fiscalbr_accept_inbox_document \u2014 check by hand';\n END IF;\n EXECUTE replace(v_def, v_old, v_new);\nEND\n$m$;\n";
|
|
30
30
|
export declare const MIGRATION_029_THE_SERVICE_WRITES_THE_INTAKE_HEALTH = "-- ============================================================================\n-- 029_the_service_writes_the_intake_health \u2014 the DF-e pull moves to the\n-- integration service (ADR 0003 phase 2).\n--\n-- The pull reads a tenant's emitter rows AS the caller (RLS decides which), and\n-- then records each CNPJ's outcome in three health columns, even for a reader\n-- without settings.manage, so the notes list can say \"intake stopped\". The\n-- edge function did that with service_role; the service gets exactly those\n-- three columns, by row id, and nothing else on the table.\n--\n-- Guarded: the role is an operator step. Replay-safe.\n-- ============================================================================\n\nDO $$\nBEGIN\n IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_integration')\n AND to_regclass('public.plg_fiscalbr_emitter_settings') IS NOT NULL THEN\n GRANT SELECT (id) ON public.plg_fiscalbr_emitter_settings TO fayz_integration;\n GRANT UPDATE (dfe_last_sync_at, dfe_last_error, dfe_last_error_at) ON public.plg_fiscalbr_emitter_settings TO fayz_integration;\n DROP POLICY IF EXISTS plg_fiscalbr_emitter_settings_service_health ON public.plg_fiscalbr_emitter_settings;\n CREATE POLICY plg_fiscalbr_emitter_settings_service_health ON public.plg_fiscalbr_emitter_settings\n FOR UPDATE TO fayz_integration USING (true) WITH CHECK (true);\n DROP POLICY IF EXISTS plg_fiscalbr_emitter_settings_service_read ON public.plg_fiscalbr_emitter_settings;\n CREATE POLICY plg_fiscalbr_emitter_settings_service_read ON public.plg_fiscalbr_emitter_settings\n FOR SELECT TO fayz_integration USING (true);\n END IF;\nEND $$;\n";
|
|
31
31
|
export declare const MIGRATION_030_THE_SERVICE_ONBOARDS_THE_EMITTER = "-- ============================================================================\n-- 030 \u2014 the integration service onboards the emitter\n--\n-- The integration service registers the company at PlugNotas (certificate,\n-- company, NF-e and NFC-e configuration, webhook) from the integration screen\n-- (control-sdk-backend ADR 0008). The emitter's facts stay here: the legal\n-- entity (public.company_legal_entities) and its settings\n-- (plg_fiscalbr_emitter_settings, one row per CNPJ). These are the doors the\n-- service reads and writes them through, one function per action, the\n-- tenant explicit, open only to `fayz_integration`.\n--\n-- NF-e gets what NFC-e already had: environment, series and the next number\n-- (numbering continues from the previous system, so no number is skipped or\n-- used twice). The CSC token stays write-only for the screen; the service\n-- reads it here because PlugNotas needs it to sign NFC-e.\n-- ============================================================================\n\nALTER TABLE public.plg_fiscalbr_emitter_settings\n ADD COLUMN IF NOT EXISTS nfe_production boolean NOT NULL DEFAULT false,\n ADD COLUMN IF NOT EXISTS nfe_series integer CHECK (nfe_series IS NULL OR nfe_series > 0),\n ADD COLUMN IF NOT EXISTS nfe_next_number bigint CHECK (nfe_next_number IS NULL OR nfe_next_number > 0),\n ADD COLUMN IF NOT EXISTS nfce_next_number bigint CHECK (nfce_next_number IS NULL OR nfce_next_number > 0),\n ADD COLUMN IF NOT EXISTS provider_webhook_at timestamptz,\n -- PlugNotas' codes. The legal entity's tax_regime is free text copied from\n -- the V1 ('simples_nacional', 'other'); guessing a regime from 'other' would\n -- register the company wrong, so the store states it once.\n ADD COLUMN IF NOT EXISTS tax_regime_code smallint CHECK (tax_regime_code IS NULL OR tax_regime_code BETWEEN 1 AND 3),\n ADD COLUMN IF NOT EXISTS special_tax_regime_code smallint NOT NULL DEFAULT 0 CHECK (special_tax_regime_code BETWEEN 0 AND 11);\n\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfe_next_number IS\n 'The next NF-e number PlugNotas will use for nfe_series; set when the store moves from another system.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.tax_regime_code IS\n '1 Simples Nacional, 2 Simples Nacional over the sublimit, 3 normal regime (presumed or real profit).';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.provider_webhook_at IS\n 'When the integration service registered its webhook for this CNPJ at PlugNotas.';\n\n-- One emitter as the service needs it: the legal entity and its settings,\n-- CSC token included. NULL when the legal entity is not the tenant's.\nCREATE OR REPLACE FUNCTION public.fiscalbr_emitter_for_service(p_tenant uuid, p_legal_entity uuid)\nRETURNS jsonb\nLANGUAGE sql STABLE SECURITY DEFINER\nSET search_path TO ''\nAS $$\n SELECT jsonb_build_object(\n 'legalEntityId', l.id,\n 'tenantId', l.tenant_id,\n 'cnpj', regexp_replace(coalesce(l.document_number, ''), '\\D', '', 'g'),\n 'legalName', l.legal_name,\n 'tradeName', l.trade_name,\n 'stateRegistration', l.state_registration,\n 'municipalRegistration', l.municipal_registration,\n 'taxRegime', l.tax_regime,\n -- Stated on the emitter, else read from a regime the text names unambiguously.\n 'taxRegimeCode', coalesce(s.tax_regime_code, CASE WHEN l.tax_regime = 'simples_nacional' THEN 1 END),\n 'specialTaxRegimeCode', coalesce(s.special_tax_regime_code, 0),\n 'cnae', l.cnae,\n 'email', coalesce(nullif(l.fiscal_email, ''), l.email),\n 'phone', l.phone,\n -- The address is a row of public.addresses (142); the legal entity's own\n -- columns are the retired shape, read only when no address row exists.\n 'address', CASE WHEN a.id IS NOT NULL THEN jsonb_build_object(\n 'street', a.street, 'number', a.number, 'complement', a.complement,\n 'district', a.district, 'city', a.city, 'state', a.state, 'postalCode', a.postal_code,\n 'ibgeCityCode', l.ibge_city_code, 'country', a.country\n ) ELSE jsonb_build_object(\n 'street', l.address, 'number', l.address_number, 'complement', l.address_complement,\n 'district', l.district, 'city', l.city, 'state', l.state, 'postalCode', l.postal_code,\n 'ibgeCityCode', l.ibge_city_code, 'country', l.country\n ) END,\n 'settings', CASE WHEN s.id IS NULL THEN NULL ELSE jsonb_build_object(\n 'nfe', jsonb_build_object('enabled', s.nfe_enabled, 'production', s.nfe_production, 'series', s.nfe_series, 'nextNumber', s.nfe_next_number),\n 'nfce', jsonb_build_object('enabled', s.nfce_enabled, 'production', s.nfce_production, 'series', s.nfce_series, 'nextNumber', s.nfce_next_number,\n 'cscId', s.nfce_csc_id, 'cscToken', s.nfce_csc_token, 'genericItem', s.nfce_generic_item),\n 'certificate', jsonb_build_object('providerId', s.certificate_provider_id, 'holder', s.certificate_cn,\n 'expiresAt', s.certificate_expires_at, 'uploadedAt', s.certificate_uploaded_at),\n 'providerSyncedAt', s.provider_synced_at,\n 'providerWebhookAt', s.provider_webhook_at\n ) END\n )\n FROM public.company_legal_entities l\n LEFT JOIN public.plg_fiscalbr_emitter_settings s ON s.legal_entity_id = l.id AND s.tenant_id = l.tenant_id\n LEFT JOIN LATERAL (\n SELECT * FROM public.addresses x\n WHERE x.tenant_id = l.tenant_id AND x.owner_type = 'company_legal_entity' AND x.owner_id = l.id\n ORDER BY (x.kind = 'fiscal') DESC, x.is_default DESC, x.created_at\n LIMIT 1\n ) a ON true\n WHERE l.id = p_legal_entity AND l.tenant_id = p_tenant\n$$;\n\n-- Every legal entity of the tenant that a unit points at: the emitters a\n-- screen can set up.\nCREATE OR REPLACE FUNCTION public.fiscalbr_emitters_for_service(p_tenant uuid)\nRETURNS SETOF jsonb\nLANGUAGE sql STABLE SECURITY DEFINER\nSET search_path TO ''\nAS $$\n SELECT public.fiscalbr_emitter_for_service(p_tenant, l.id)\n FROM public.company_legal_entities l\n WHERE l.tenant_id = p_tenant\n AND EXISTS (SELECT 1 FROM app.units u WHERE u.tenant_id = p_tenant AND u.company_legal_entity_id = l.id)\n ORDER BY l.legal_name\n$$;\n\n-- Records what the service set at PlugNotas. Only the keys below move; the\n-- row is created on first write.\nCREATE OR REPLACE FUNCTION public.fiscalbr_emitter_record(p_tenant uuid, p_legal_entity uuid, p_patch jsonb)\nRETURNS void\nLANGUAGE plpgsql SECURITY DEFINER\nSET search_path TO ''\nAS $$\nDECLARE\n v_unknown text;\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM public.company_legal_entities WHERE id = p_legal_entity AND tenant_id = p_tenant) THEN\n RAISE EXCEPTION 'fiscalbr_emitter_record: legal entity % is not of tenant %', p_legal_entity, p_tenant USING ERRCODE = 'P0002';\n END IF;\n SELECT k INTO v_unknown FROM jsonb_object_keys(p_patch) k\n WHERE k NOT IN ('nfe_enabled', 'nfe_production', 'nfe_series', 'nfe_next_number',\n 'nfce_enabled', 'nfce_production', 'nfce_series', 'nfce_next_number', 'nfce_csc_id', 'nfce_csc_token',\n 'certificate_provider_id', 'certificate_cn', 'certificate_expires_at', 'certificate_uploaded_at',\n 'provider_synced_at', 'provider_webhook_at', 'tax_regime_code', 'special_tax_regime_code')\n LIMIT 1;\n IF v_unknown IS NOT NULL THEN\n RAISE EXCEPTION 'fiscalbr_emitter_record: % is not recorded here', v_unknown USING ERRCODE = '22023';\n END IF;\n\n INSERT INTO public.plg_fiscalbr_emitter_settings (tenant_id, legal_entity_id)\n VALUES (p_tenant, p_legal_entity)\n ON CONFLICT (legal_entity_id) DO NOTHING;\n\n UPDATE public.plg_fiscalbr_emitter_settings s SET\n nfe_enabled = CASE WHEN p_patch ? 'nfe_enabled' THEN (p_patch ->> 'nfe_enabled')::boolean ELSE s.nfe_enabled END,\n nfe_production = CASE WHEN p_patch ? 'nfe_production' THEN (p_patch ->> 'nfe_production')::boolean ELSE s.nfe_production END,\n nfe_series = CASE WHEN p_patch ? 'nfe_series' THEN (p_patch ->> 'nfe_series')::integer ELSE s.nfe_series END,\n nfe_next_number = CASE WHEN p_patch ? 'nfe_next_number' THEN (p_patch ->> 'nfe_next_number')::bigint ELSE s.nfe_next_number END,\n nfce_enabled = CASE WHEN p_patch ? 'nfce_enabled' THEN (p_patch ->> 'nfce_enabled')::boolean ELSE s.nfce_enabled END,\n nfce_production = CASE WHEN p_patch ? 'nfce_production' THEN (p_patch ->> 'nfce_production')::boolean ELSE s.nfce_production END,\n nfce_series = CASE WHEN p_patch ? 'nfce_series' THEN (p_patch ->> 'nfce_series')::integer ELSE s.nfce_series END,\n nfce_next_number = CASE WHEN p_patch ? 'nfce_next_number' THEN (p_patch ->> 'nfce_next_number')::bigint ELSE s.nfce_next_number END,\n nfce_csc_id = CASE WHEN p_patch ? 'nfce_csc_id' THEN p_patch ->> 'nfce_csc_id' ELSE s.nfce_csc_id END,\n nfce_csc_token = CASE WHEN p_patch ? 'nfce_csc_token' THEN p_patch ->> 'nfce_csc_token' ELSE s.nfce_csc_token END,\n certificate_provider_id = CASE WHEN p_patch ? 'certificate_provider_id' THEN p_patch ->> 'certificate_provider_id' ELSE s.certificate_provider_id END,\n certificate_cn = CASE WHEN p_patch ? 'certificate_cn' THEN p_patch ->> 'certificate_cn' ELSE s.certificate_cn END,\n certificate_expires_at = CASE WHEN p_patch ? 'certificate_expires_at' THEN (p_patch ->> 'certificate_expires_at')::timestamptz ELSE s.certificate_expires_at END,\n certificate_uploaded_at = CASE WHEN p_patch ? 'certificate_uploaded_at' THEN (p_patch ->> 'certificate_uploaded_at')::timestamptz ELSE s.certificate_uploaded_at END,\n provider_synced_at = CASE WHEN p_patch ? 'provider_synced_at' THEN (p_patch ->> 'provider_synced_at')::timestamptz ELSE s.provider_synced_at END,\n provider_webhook_at = CASE WHEN p_patch ? 'provider_webhook_at' THEN (p_patch ->> 'provider_webhook_at')::timestamptz ELSE s.provider_webhook_at END,\n tax_regime_code = CASE WHEN p_patch ? 'tax_regime_code' THEN (p_patch ->> 'tax_regime_code')::smallint ELSE s.tax_regime_code END,\n special_tax_regime_code = CASE WHEN p_patch ? 'special_tax_regime_code' THEN coalesce((p_patch ->> 'special_tax_regime_code')::smallint, 0) ELSE s.special_tax_regime_code END,\n updated_at = now()\n WHERE s.legal_entity_id = p_legal_entity AND s.tenant_id = p_tenant;\nEND\n$$;\n\nREVOKE ALL ON FUNCTION public.fiscalbr_emitter_for_service(uuid, uuid) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.fiscalbr_emitters_for_service(uuid) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.fiscalbr_emitter_record(uuid, uuid, jsonb) FROM PUBLIC, anon, authenticated;\n\nDO $$\nBEGIN\n IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_integration') THEN\n GRANT USAGE ON SCHEMA public TO fayz_integration;\n GRANT EXECUTE ON FUNCTION public.fiscalbr_emitter_for_service(uuid, uuid) TO fayz_integration;\n GRANT EXECUTE ON FUNCTION public.fiscalbr_emitters_for_service(uuid) TO fayz_integration;\n GRANT EXECUTE ON FUNCTION public.fiscalbr_emitter_record(uuid, uuid, jsonb) TO fayz_integration;\n END IF;\nEND\n$$;\n";
|
|
32
|
+
export declare const MIGRATION_031_THE_SERVICE_ISSUES_THE_NOTE = "-- ============================================================================\n-- 031 \u2014 the integration service issues the order's note\n--\n-- control-sdk-backend ADR 0008, step 2: an operator clicks \"Emitir NFC-e\" on\n-- an order and the integration service builds the note with the V1's rules,\n-- sends it to PlugNotas and records what comes back. The note is the record\n-- 005 already defined (public.documents + plg_fiscalbr_documents, linked to\n-- the order by plg_fiscalbr_document_orders); these are the service's doors to\n-- it, one function per action, the tenant explicit, open only to\n-- `fayz_integration`.\n--\n-- fiscalbr_order_for_note_for_service what the note is made of\n-- fiscalbr_note_open_for_service the note, opened before the call\n-- fiscalbr_note_apply_for_service what PlugNotas answered\n-- fiscalbr_notes_for_service an order's notes\n-- fiscalbr_note_for_service one note\n-- fiscalbr_notes_to_follow_for_service notes still waiting on PlugNotas\n--\n-- A note is opened BEFORE PlugNotas is called, with the idIntegracao it will\n-- carry, so a call whose answer is lost is found again by that id instead of\n-- being sent twice (the V1's FISCAL_PROVIDER_RESULT_UNCERTAIN rule).\n-- ============================================================================\n\n-- What the note of an order is made of: the order, its lines with their tax\n-- data, what was paid and how, who bought and where it goes. The service\n-- applies the rules; this only gathers.\nCREATE OR REPLACE FUNCTION public.fiscalbr_order_for_note_for_service(p_tenant uuid, p_order uuid)\nRETURNS jsonb\nLANGUAGE plpgsql STABLE SECURITY DEFINER\nSET search_path TO ''\nAS $$\nDECLARE\n v_checkout jsonb;\nBEGIN\n -- The shop is optional: a site order says how it was paid on its checkout,\n -- read only where the shop's table exists.\n IF to_regclass('public.plg_shop_checkout_sessions') IS NOT NULL THEN\n EXECUTE $q$\n SELECT jsonb_build_object(\n 'paymentMethod', s.payment_method, 'provider', s.provider, 'total', s.total, 'status', s.status,\n 'customerName', s.customer_name, 'customerDocument', s.customer_document,\n 'destinationState', s.shipping_address ->> 'state', 'destinationCity', s.shipping_address ->> 'city')\n FROM public.plg_shop_checkout_sessions s\n WHERE s.order_id = $1 AND s.tenant_id = $2 AND s.status = 'paid'\n ORDER BY s.updated_at DESC LIMIT 1 $q$\n INTO v_checkout USING p_order, p_tenant;\n END IF;\n\n RETURN (SELECT jsonb_build_object(\n 'orderId', o.id,\n 'tenantId', o.tenant_id,\n 'unitId', o.unit_id,\n 'kind', o.kind,\n 'status', o.status,\n 'channel', o.channel,\n 'createdAt', o.created_at,\n 'subtotal', o.subtotal,\n 'discount', o.discount,\n 'shipping', o.shipping,\n 'total', o.total,\n 'legalEntityId', u.company_legal_entity_id,\n 'items', coalesce((\n SELECT jsonb_agg(jsonb_build_object(\n 'itemId', i.id,\n 'productId', i.product_id,\n 'code', coalesce(nullif(p.sku, ''), i.product_id::text, i.id::text),\n 'name', i.name,\n 'quantity', i.quantity,\n 'unitPrice', i.unit_price,\n 'discount', i.discount,\n 'total', i.total,\n 'cancelled', i.cancelled_at IS NOT NULL,\n -- The product's own value wins, field by field, over its group's (V1).\n 'tax', CASE WHEN t.product_id IS NULL AND g.id IS NULL THEN NULL ELSE jsonb_build_object(\n 'ncm', t.ncm,\n 'cest', coalesce(t.cest, g.cest),\n 'cfop', coalesce(nullif(t.cfop, ''), g.cfop),\n 'cst', coalesce(nullif(t.cst, ''), g.cst),\n 'csosn', g.csosn,\n 'origin', coalesce(nullif(t.icms_origin, ''), g.icms_origin),\n 'icmsRate', CASE WHEN coalesce(t.icms_rate, 0) > 0 THEN t.icms_rate ELSE g.icms_rate END,\n 'icmsBaseMode', g.icms_bc_mode,\n 'pisSituation', coalesce(nullif(t.pis_situation, ''), g.pis_situation),\n 'pisRate', CASE WHEN coalesce(t.pis_rate, 0) > 0 THEN t.pis_rate ELSE g.pis_rate END,\n 'cofinsSituation', coalesce(nullif(t.cofins_situation, ''), g.cofins_situation),\n 'cofinsRate', CASE WHEN coalesce(t.cofins_rate, 0) > 0 THEN t.cofins_rate ELSE g.cofins_rate END,\n 'ibsCbsSituation', coalesce(nullif(t.ibs_cbs_situation, ''), g.ibs_cbs_situation),\n 'ibsCbsClassification', coalesce(nullif(t.ibs_cbs_classification, ''), g.ibs_cbs_classification),\n 'group', g.name\n ) END\n ) ORDER BY i.sort_order, i.created_at)\n FROM public.items i\n LEFT JOIN public.products p ON p.id = i.product_id AND p.tenant_id = i.tenant_id\n LEFT JOIN public.plg_fiscalbr_product_tax_info t ON t.product_id = i.product_id AND t.tenant_id = i.tenant_id\n LEFT JOIN public.plg_fiscalbr_tax_groups g ON g.id = t.tax_group_id AND g.tenant_id = i.tenant_id\n WHERE i.order_id = o.id AND i.tenant_id = o.tenant_id\n ), '[]'::jsonb),\n -- Anything that is not a site order is paid through the movements the\n -- order received, net of reversals, by method.\n 'checkout', v_checkout,\n 'payments', coalesce((\n SELECT jsonb_agg(jsonb_build_object('method', x.method, 'methodType', x.method_type, 'amount', x.amount))\n FROM (\n SELECT pm.name AS method, pt.name AS method_type,\n sum(CASE WHEN m.direction = 'credit' THEN m.paid_amount ELSE -m.paid_amount END) AS amount\n FROM public.plg_financial_movements m\n LEFT JOIN public.plg_financial_payment_methods pm ON pm.id = m.payment_method_id\n LEFT JOIN public.plg_financial_payment_method_types pt ON pt.id = pm.payment_method_type_id\n WHERE m.tenant_id = o.tenant_id AND m.movement_kind = 'payment' AND m.status = 'paid'\n AND (m.invoice_id = o.id OR m.financial_invoice_id IN (\n SELECT fi.id FROM public.plg_financial_invoices fi WHERE fi.order_id = o.id AND fi.tenant_id = o.tenant_id))\n GROUP BY pm.name, pt.name\n ) x WHERE x.amount <> 0\n ), '[]'::jsonb),\n 'notes', coalesce((\n SELECT jsonb_agg(jsonb_build_object('documentId', d.document_id, 'model', d.model, 'status', d.status))\n FROM public.plg_fiscalbr_documents d\n WHERE d.tenant_id = o.tenant_id AND d.order_id = o.id\n ), '[]'::jsonb)\n )\n FROM public.orders o\n LEFT JOIN app.units u ON u.id = o.unit_id AND u.tenant_id = o.tenant_id\n WHERE o.id = p_order AND o.tenant_id = p_tenant);\nEND\n$$;\n\n-- The note, opened before PlugNotas is called. One live note per order and\n-- model: a second \"Emitir\" while one is processing or authorized is refused\n-- (P0001 note_exists), so a double click never issues twice.\nCREATE OR REPLACE FUNCTION public.fiscalbr_note_open_for_service(\n p_tenant uuid, p_order uuid, p_model text, p_input jsonb\n)\nRETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER\nSET search_path TO ''\nAS $$\nDECLARE\n v_order public.orders%ROWTYPE;\n v_doc uuid := gen_random_uuid();\n v_integration text;\n v_existing uuid;\nBEGIN\n IF p_model NOT IN ('nfce', 'nfe') THEN\n RAISE EXCEPTION 'fiscalbr_note_open: model % is not issued here', p_model USING ERRCODE = '22023';\n END IF;\n SELECT * INTO v_order FROM public.orders WHERE id = p_order AND tenant_id = p_tenant FOR UPDATE;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'fiscalbr_note_open: order % is not of tenant %', p_order, p_tenant USING ERRCODE = 'P0002';\n END IF;\n SELECT document_id INTO v_existing FROM public.plg_fiscalbr_documents\n WHERE tenant_id = p_tenant AND order_id = p_order AND model = p_model AND status IN ('processing', 'authorized')\n LIMIT 1;\n IF v_existing IS NOT NULL THEN\n RAISE EXCEPTION 'note_exists' USING ERRCODE = 'P0001', DETAIL = v_existing::text;\n END IF;\n\n -- Ours, and unique per company at PlugNotas: how a lost answer is found again.\n v_integration := 'csb-' || v_doc::text;\n\n INSERT INTO public.documents (id, tenant_id, kind, title, status, unit_id, subject_type, subject_id, created_by, metadata)\n VALUES (v_doc, p_tenant, 'fiscal_document', upper(p_model) || ' do pedido', 'processing', v_order.unit_id,\n 'order', p_order, nullif(p_input ->> 'userId', '')::uuid, jsonb_build_object('source', 'integration-service'));\n\n INSERT INTO public.plg_fiscalbr_documents (\n document_id, tenant_id, unit_id, order_id, model, environment, status, series, provider,\n provider_integration_id, total_value, recipient_snapshot, request_payload, source_snapshot, emitted_by_user_id, metadata)\n VALUES (\n v_doc, p_tenant, v_order.unit_id, p_order, p_model, p_input ->> 'environment', 'processing', p_input ->> 'series', 'plugnotas',\n v_integration, (p_input ->> 'total')::numeric, p_input -> 'recipient', p_input -> 'request', p_input -> 'source',\n nullif(p_input ->> 'userId', '')::uuid, jsonb_build_object('emitterCnpj', p_input ->> 'emitterCnpj'));\n\n INSERT INTO public.plg_fiscalbr_document_orders (tenant_id, document_id, order_id)\n VALUES (p_tenant, v_doc, p_order);\n\n INSERT INTO public.plg_fiscalbr_document_events (tenant_id, unit_id, document_id, event_type, status, created_by_user_id)\n VALUES (p_tenant, v_order.unit_id, v_doc, 'issue_requested', 'processing', nullif(p_input ->> 'userId', '')::uuid);\n\n RETURN jsonb_build_object('documentId', v_doc, 'integrationId', v_integration);\nEND\n$$;\n\n-- What PlugNotas answered, on the note and on its public.documents row (the\n-- same note seen from two places, 005). Identity (model, unit, order) never moves.\nCREATE OR REPLACE FUNCTION public.fiscalbr_note_apply_for_service(\n p_tenant uuid, p_document uuid, p_patch jsonb, p_event jsonb DEFAULT NULL\n)\nRETURNS void\nLANGUAGE plpgsql SECURITY 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 AND tenant_id = p_tenant FOR UPDATE;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'fiscalbr_note_apply: note % is not of tenant %', p_document, p_tenant USING ERRCODE = 'P0002';\n END IF;\n IF v_status IS NOT NULL AND v_status NOT IN ('processing', 'authorized', 'rejected', 'error', 'cancelled', 'substituted') THEN\n RAISE EXCEPTION 'fiscalbr_note_apply: unknown status %', v_status USING ERRCODE = '22023';\n END IF;\n\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 protocol = coalesce(p_patch ->> 'protocol', protocol),\n provider_document_id = coalesce(p_patch ->> 'provider_document_id', provider_document_id),\n response_payload = coalesce(p_patch -> 'response_payload', response_payload),\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 xml = coalesce(p_patch ->> 'xml', xml),\n authorized_at = coalesce((p_patch ->> 'authorized_at')::timestamptz, authorized_at),\n last_polled_at = CASE WHEN p_patch ? 'polled' 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_patch ? 'cancelled_by_user_id' THEN nullif(p_patch ->> 'cancelled_by_user_id', '')::uuid ELSE cancelled_by_user_id END,\n metadata = metadata || coalesce(p_patch -> 'metadata', '{}'::jsonb)\n WHERE document_id = p_document;\n\n IF v_status IS NOT NULL THEN\n UPDATE public.documents SET status = v_status WHERE id = p_document 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, p_event ->> 'event_type', p_event ->> 'status',\n p_event ->> 'justification', p_event -> 'response_payload', nullif(p_event ->> 'userId', '')::uuid);\n END IF;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION public.fiscalbr_note_for_service(p_tenant uuid, p_document uuid)\nRETURNS jsonb\nLANGUAGE sql STABLE SECURITY DEFINER\nSET search_path TO ''\nAS $$\n SELECT jsonb_build_object(\n 'documentId', d.document_id, 'tenantId', d.tenant_id, 'unitId', d.unit_id, 'orderId', d.order_id,\n 'model', d.model, 'environment', d.environment, 'status', d.status, 'series', d.series, 'number', d.number,\n 'accessKey', d.access_key, 'protocol', d.protocol, 'provider', d.provider,\n 'providerDocumentId', d.provider_document_id, 'integrationId', d.provider_integration_id,\n 'total', d.total_value, 'recipient', d.recipient_snapshot, 'rejection', d.rejection_payload,\n 'hasXml', d.xml IS NOT NULL, 'authorizedAt', d.authorized_at, 'lastPolledAt', d.last_polled_at,\n 'cancelledAt', d.cancelled_at, 'cancelReason', d.cancel_reason, 'createdAt', d.created_at,\n 'emitterCnpj', d.metadata ->> 'emitterCnpj', 'metadata', d.metadata)\n FROM public.plg_fiscalbr_documents d\n WHERE d.document_id = p_document AND d.tenant_id = p_tenant\n$$;\n\nCREATE OR REPLACE FUNCTION public.fiscalbr_notes_for_service(p_tenant uuid, p_order uuid)\nRETURNS SETOF jsonb\nLANGUAGE sql STABLE SECURITY DEFINER\nSET search_path TO ''\nAS $$\n SELECT public.fiscalbr_note_for_service(p_tenant, d.document_id)\n FROM public.plg_fiscalbr_documents d\n WHERE d.tenant_id = p_tenant AND d.order_id = p_order\n ORDER BY d.created_at DESC\n$$;\n\n-- The XML is large and read only on download.\nCREATE OR REPLACE FUNCTION public.fiscalbr_note_xml_for_service(p_tenant uuid, p_document uuid)\nRETURNS text\nLANGUAGE sql STABLE SECURITY DEFINER\nSET search_path TO ''\nAS $$\n SELECT d.xml FROM public.plg_fiscalbr_documents d WHERE d.document_id = p_document AND d.tenant_id = p_tenant\n$$;\n\n-- Notes this service sent that PlugNotas has not settled: the fallback when a\n-- webhook does not arrive. Oldest poll first.\nCREATE OR REPLACE FUNCTION public.fiscalbr_notes_to_follow_for_service(p_limit integer DEFAULT 25)\nRETURNS SETOF jsonb\nLANGUAGE sql STABLE SECURITY DEFINER\nSET search_path TO ''\nAS $$\n SELECT public.fiscalbr_note_for_service(d.tenant_id, d.document_id)\n FROM public.plg_fiscalbr_documents d\n WHERE d.provider = 'plugnotas' AND d.status = 'processing' AND d.provider_integration_id LIKE 'csb-%'\n AND d.created_at > now() - interval '7 days'\n ORDER BY coalesce(d.last_polled_at, d.created_at)\n LIMIT greatest(1, least(p_limit, 100))\n$$;\n\nDO $$\nDECLARE\n f text;\nBEGIN\n FOREACH f IN ARRAY ARRAY[\n 'public.fiscalbr_order_for_note_for_service(uuid, uuid)',\n 'public.fiscalbr_note_open_for_service(uuid, uuid, text, jsonb)',\n 'public.fiscalbr_note_apply_for_service(uuid, uuid, jsonb, jsonb)',\n 'public.fiscalbr_note_for_service(uuid, uuid)',\n 'public.fiscalbr_notes_for_service(uuid, uuid)',\n 'public.fiscalbr_note_xml_for_service(uuid, uuid)',\n 'public.fiscalbr_notes_to_follow_for_service(integer)'\n ] LOOP\n EXECUTE format('REVOKE ALL ON FUNCTION %s FROM PUBLIC, anon, authenticated', f);\n IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_integration') THEN\n EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO fayz_integration', f);\n END IF;\n END LOOP;\nEND\n$$;\n";
|
|
32
33
|
export declare const MIGRATIONS: Array<{
|
|
33
34
|
id: string;
|
|
34
35
|
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,0CAA0C,83CAwBtD,CAAA;AAED,eAAO,MAAM,+CAA+C,81JAiG3D,CAAA;AAED,eAAO,MAAM,gDAAgD,y+IAgH5D,CAAA;AAED,eAAO,MAAM,4CAA4C,ylEA4CxD,CAAA;AAED,eAAO,MAAM,qCAAqC,8kCAkBjD,CAAA;AAED,eAAO,MAAM,uDAAuD,u6HAyEnE,CAAA;AAED,eAAO,MAAM,oDAAoD,6/KAoHhE,CAAA;AAED,eAAO,MAAM,+CAA+C,uqPAwJ3D,CAAA;AAED,eAAO,MAAM,8CAA8C,myCAoB1D,CAAA;AAED,eAAO,MAAM,0DAA0D,yyHAsEtE,CAAA;AAED,eAAO,MAAM,4CAA4C,+jDA8BxD,CAAA;AAED,eAAO,MAAM,kDAAkD,0pDA2B9D,CAAA;AAED,eAAO,MAAM,8CAA8C,m3VAyK1D,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,
|
|
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,gDAAgD,y+IAgH5D,CAAA;AAED,eAAO,MAAM,4CAA4C,ylEA4CxD,CAAA;AAED,eAAO,MAAM,qCAAqC,8kCAkBjD,CAAA;AAED,eAAO,MAAM,uDAAuD,u6HAyEnE,CAAA;AAED,eAAO,MAAM,oDAAoD,6/KAoHhE,CAAA;AAED,eAAO,MAAM,+CAA+C,uqPAwJ3D,CAAA;AAED,eAAO,MAAM,8CAA8C,myCAoB1D,CAAA;AAED,eAAO,MAAM,0DAA0D,yyHAsEtE,CAAA;AAED,eAAO,MAAM,4CAA4C,+jDA8BxD,CAAA;AAED,eAAO,MAAM,kDAAkD,0pDA2B9D,CAAA;AAED,eAAO,MAAM,8CAA8C,m3VAyK1D,CAAA;AAED,eAAO,MAAM,yCAAyC,oifAoTrD,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAiCzD,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"OrderNoteCard.d.ts","sourceRoot":"","sources":["../../src/views/OrderNoteCard.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAuCzB,wBAAgB,aAAa,CAAC,EAAE,OAAO,EAAE,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,4BAwH9D"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fayz-ai/plugin-fiscal-br",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "[preview] Brazilian fiscal addon for asynchronous NFC-e emission through PlugNotas",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@tanstack/react-table": "^8.20.0",
|
|
31
|
-
"@fayz-ai/admin": "^0.
|
|
31
|
+
"@fayz-ai/admin": "^0.58.0",
|
|
32
32
|
"@fayz-ai/core": "^0.57.0",
|
|
33
|
-
"@fayz-ai/financial": "^0.1.
|
|
33
|
+
"@fayz-ai/financial": "^0.1.7",
|
|
34
34
|
"@fayz-ai/ui": "^0.57.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
// fiscalbr-certificate — send a CNPJ's A1 certificate (.pfx + password) to PlugNotas.
|
|
2
|
-
//
|
|
3
|
-
// Env: PLUGNOTAS_API_KEY (server-only), PLUGNOTAS_BASE_URL (optional), SUPABASE_URL, SUPABASE_ANON_KEY.
|
|
4
|
-
//
|
|
5
|
-
// Port of the V1 `dfe-plugnotas-certificate`, with two differences:
|
|
6
|
-
// - Validity and holder are read from the file itself. The V1 took them from
|
|
7
|
-
// PlugNotas' answer, which does not carry them in the shape it read, so no
|
|
8
|
-
// company in the V1 ever had an expiry date.
|
|
9
|
-
// - A CNPJ that already has a certificate there is REPLACED in place (same
|
|
10
|
-
// id). The V1 hourly sync re-registers the company with the id it stored; a
|
|
11
|
-
// new id would leave that sync patching the expired one forever.
|
|
12
|
-
// The file is never stored. The row is written with the caller's JWT, so the
|
|
13
|
-
// emitter-settings policy (settings.manage) decides who may do this.
|
|
14
|
-
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.45.0'
|
|
15
|
-
import forge from 'npm:node-forge@1.3.1'
|
|
16
|
-
|
|
17
|
-
const corsHeaders = {
|
|
18
|
-
'Access-Control-Allow-Origin': '*',
|
|
19
|
-
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const json = (body: unknown, status = 200) =>
|
|
23
|
-
new Response(JSON.stringify(body), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' } })
|
|
24
|
-
|
|
25
|
-
// deno-lint-ignore no-explicit-any
|
|
26
|
-
type Any = any
|
|
27
|
-
|
|
28
|
-
const API = () => (Deno.env.get('PLUGNOTAS_BASE_URL') || 'https://api.plugnotas.com.br').replace(/\/$/, '')
|
|
29
|
-
const digits = (v: unknown) => String(v ?? '').replace(/\D/g, '')
|
|
30
|
-
|
|
31
|
-
function parse(text: string): Any {
|
|
32
|
-
try { return JSON.parse(text) } catch { return null }
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function bytesFromBase64(b64: string): Uint8Array {
|
|
36
|
-
const bin = atob(b64.replace(/^data:[^;]+;base64,/, ''))
|
|
37
|
-
const out = new Uint8Array(bin.length)
|
|
38
|
-
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
|
39
|
-
return out
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Holder, CNPJ and validity from the .pfx; throws a pt-BR message on a wrong password or a bad file. */
|
|
43
|
-
function readPfx(bytes: Uint8Array, password: string): { cn: string; cnpj: string; notAfter: Date } {
|
|
44
|
-
let p12: Any
|
|
45
|
-
try {
|
|
46
|
-
let bin = ''
|
|
47
|
-
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
|
|
48
|
-
p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(bin), password)
|
|
49
|
-
} catch (e) {
|
|
50
|
-
const msg = String((e as Error)?.message ?? e)
|
|
51
|
-
throw new Error(/mac|password|decrypt/i.test(msg)
|
|
52
|
-
? 'A senha não abre este certificado. Confira a senha do arquivo .pfx.'
|
|
53
|
-
: 'Este arquivo não é um certificado A1 (.pfx) válido.')
|
|
54
|
-
}
|
|
55
|
-
const bags = p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag] ?? []
|
|
56
|
-
// The holder's certificate is the one whose subject carries a CNPJ; the rest is the chain.
|
|
57
|
-
const certs = bags.map((b: Any) => b.cert).filter(Boolean)
|
|
58
|
-
const cnOf = (c: Any) => String(c.subject.getField('CN')?.value ?? '')
|
|
59
|
-
const holder = certs.find((c: Any) => /\d{14}/.test(cnOf(c))) ?? certs[0]
|
|
60
|
-
if (!holder) throw new Error('O arquivo não traz nenhum certificado.')
|
|
61
|
-
const cn = cnOf(holder)
|
|
62
|
-
return { cn, cnpj: (cn.match(/(\d{14})/) ?? [])[1] ?? '', notAfter: holder.validity.notAfter }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function sendToProvider(apiKey: string, bytes: Uint8Array, password: string, fileName: string, existingId: string | null) {
|
|
66
|
-
const form = () => {
|
|
67
|
-
const f = new FormData()
|
|
68
|
-
f.append('arquivo', new Blob([bytes], { type: 'application/x-pkcs12' }), fileName)
|
|
69
|
-
f.append('senha', password)
|
|
70
|
-
return f
|
|
71
|
-
}
|
|
72
|
-
if (existingId) {
|
|
73
|
-
const put = await fetch(`${API()}/certificado/${encodeURIComponent(existingId)}`, {
|
|
74
|
-
method: 'PUT', headers: { 'x-api-key': apiKey, Accept: 'application/json' }, body: form(),
|
|
75
|
-
})
|
|
76
|
-
const text = await put.text()
|
|
77
|
-
if (put.ok) return { id: String(parse(text)?.data?.id || parse(text)?.id || existingId), replaced: true }
|
|
78
|
-
// Gone on their side (or no in-place update): register a new one below. Anything else is the provider refusing.
|
|
79
|
-
if (put.status !== 404 && put.status !== 405) throw new Error(`O serviço fiscal recusou o certificado (${put.status}): ${text.slice(0, 300)}`)
|
|
80
|
-
}
|
|
81
|
-
const post = await fetch(`${API()}/certificado`, {
|
|
82
|
-
method: 'POST', headers: { 'x-api-key': apiKey, Accept: 'application/json' }, body: form(),
|
|
83
|
-
})
|
|
84
|
-
const text = await post.text()
|
|
85
|
-
if (!post.ok) throw new Error(`O serviço fiscal recusou o certificado (${post.status}): ${text.slice(0, 300)}`)
|
|
86
|
-
const body = parse(text)
|
|
87
|
-
const id = String(body?.data?.id || body?.id || '')
|
|
88
|
-
if (!id) throw new Error('O serviço fiscal aceitou o arquivo mas não devolveu o identificador do certificado.')
|
|
89
|
-
return { id, replaced: false }
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Points the company registered at PlugNotas to the certificate and keeps DF-e reception on. */
|
|
93
|
-
async function attachToCompany(apiKey: string, cnpj: string, certificateId: string): Promise<string | null> {
|
|
94
|
-
const res = await fetch(`${API()}/empresa/${cnpj}`, {
|
|
95
|
-
method: 'PATCH',
|
|
96
|
-
headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
97
|
-
body: JSON.stringify({ certificado: certificateId, nfe: { ativo: true, config: { producao: true, dfe: { ativo: true } } } }),
|
|
98
|
-
})
|
|
99
|
-
if (res.ok) return null
|
|
100
|
-
const text = await res.text()
|
|
101
|
-
// Not registered there yet: the first pull registers it with this certificate.
|
|
102
|
-
if (res.status === 404) return null
|
|
103
|
-
return `O certificado foi enviado, mas a empresa no serviço fiscal não aceitou a troca (${res.status}): ${text.slice(0, 300)}`
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
Deno.serve(async (req) => {
|
|
107
|
-
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
|
108
|
-
try {
|
|
109
|
-
const apiKey = Deno.env.get('PLUGNOTAS_API_KEY')
|
|
110
|
-
if (!apiKey) return json({ error: 'A conexão com a SEFAZ não está configurada neste ambiente.' }, 503)
|
|
111
|
-
const auth = req.headers.get('Authorization') || ''
|
|
112
|
-
if (!auth) return json({ error: 'Unauthorized' }, 401)
|
|
113
|
-
|
|
114
|
-
const body: Any = await req.json().catch(() => ({}))
|
|
115
|
-
const legalEntityId = typeof body?.legalEntityId === 'string' ? body.legalEntityId : ''
|
|
116
|
-
const password = typeof body?.password === 'string' ? body.password : ''
|
|
117
|
-
const pfx = typeof body?.pfxBase64 === 'string' ? body.pfxBase64 : ''
|
|
118
|
-
if (!legalEntityId || !pfx || !password) return json({ error: 'Envie o arquivo .pfx e a senha.' }, 400)
|
|
119
|
-
|
|
120
|
-
const tenantId = typeof body?.tenantId === 'string' ? body.tenantId : ''
|
|
121
|
-
const db = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!, {
|
|
122
|
-
global: { headers: { Authorization: auth, ...(tenantId ? { 'x-fayz-tenant': tenantId } : {}) } },
|
|
123
|
-
})
|
|
124
|
-
const { data: row, error } = await db
|
|
125
|
-
.from('plg_fiscalbr_emitter_settings')
|
|
126
|
-
.select('id, certificate_provider_id, company_legal_entities!inner(document_number)')
|
|
127
|
-
.eq('legal_entity_id', legalEntityId)
|
|
128
|
-
.maybeSingle()
|
|
129
|
-
if (error) return json({ error: error.message }, 500)
|
|
130
|
-
if (!row) return json({ error: 'Esta empresa ainda não tem configuração fiscal salva.' }, 404)
|
|
131
|
-
const cnpj = digits((row as Any).company_legal_entities?.document_number)
|
|
132
|
-
if (cnpj.length !== 14) return json({ error: 'Salve o CNPJ da empresa antes de enviar o certificado.' }, 422)
|
|
133
|
-
|
|
134
|
-
const bytes = bytesFromBase64(pfx)
|
|
135
|
-
const cert = readPfx(bytes, password)
|
|
136
|
-
if (cert.notAfter.getTime() < Date.now()) {
|
|
137
|
-
return json({ error: `Este certificado já venceu em ${cert.notAfter.toLocaleDateString('pt-BR')}. Envie o certificado renovado.` }, 422)
|
|
138
|
-
}
|
|
139
|
-
if (cert.cnpj && cert.cnpj.slice(0, 8) !== cnpj.slice(0, 8)) {
|
|
140
|
-
return json({ error: `Este certificado é do CNPJ ${cert.cnpj}, não desta empresa (${cnpj}).` }, 422)
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const sent = await sendToProvider(apiKey, bytes, password, String(body?.fileName || 'certificado.pfx'), (row as Any).certificate_provider_id || null)
|
|
144
|
-
const warning = await attachToCompany(apiKey, cnpj, sent.id)
|
|
145
|
-
|
|
146
|
-
const now = new Date().toISOString()
|
|
147
|
-
const { error: upd } = await db.from('plg_fiscalbr_emitter_settings').update({
|
|
148
|
-
certificate_provider_id: sent.id,
|
|
149
|
-
certificate_cn: cert.cn || null,
|
|
150
|
-
certificate_expires_at: cert.notAfter.toISOString(),
|
|
151
|
-
certificate_uploaded_at: now,
|
|
152
|
-
dfe_sync_enabled: true,
|
|
153
|
-
dfe_last_error: warning,
|
|
154
|
-
dfe_last_error_at: warning ? now : null,
|
|
155
|
-
}).eq('id', (row as Any).id)
|
|
156
|
-
if (upd) return json({ error: `O certificado foi enviado, mas não consegui salvar aqui: ${upd.message}` }, 403)
|
|
157
|
-
|
|
158
|
-
return json({ ok: true, cn: cert.cn, expiresAt: cert.notAfter.toISOString(), replaced: sent.replaced, warning })
|
|
159
|
-
} catch (e) {
|
|
160
|
-
return json({ error: e instanceof Error ? e.message : String(e) }, 400)
|
|
161
|
-
}
|
|
162
|
-
})
|
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
// fiscalbr-plugnotas — consult, cancel and fetch the PDF of an issued note.
|
|
2
|
-
//
|
|
3
|
-
// Env: PLUGNOTAS_API_KEY (server-only), PLUGNOTAS_BASE_URL (optional; the
|
|
4
|
-
// sandbox host for tests), SUPABASE_URL, SUPABASE_ANON_KEY.
|
|
5
|
-
//
|
|
6
|
-
// Port of the V1 `fiscal-plugnotas-issue` (cancel / poll_status / get_pdf).
|
|
7
|
-
// Unlike the V1, nothing here runs as service role: every read and write goes
|
|
8
|
-
// through `fiscalbr_note_for_provider` / `fiscalbr_note_apply` with the
|
|
9
|
-
// caller's JWT, which check financial.read / financial.edit on the note's unit.
|
|
10
|
-
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.45.0'
|
|
11
|
-
|
|
12
|
-
const corsHeaders = {
|
|
13
|
-
'Access-Control-Allow-Origin': '*',
|
|
14
|
-
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const json = (body: unknown, status = 200) =>
|
|
18
|
-
new Response(JSON.stringify(body), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' } })
|
|
19
|
-
|
|
20
|
-
// deno-lint-ignore no-explicit-any
|
|
21
|
-
type Any = any
|
|
22
|
-
|
|
23
|
-
const API = () => (Deno.env.get('PLUGNOTAS_BASE_URL') || 'https://api.plugnotas.com.br').replace(/\/$/, '')
|
|
24
|
-
|
|
25
|
-
/** PlugNotas answers 502/504 under load; retry those, never a 4xx. */
|
|
26
|
-
async function pnFetch(path: string, init: RequestInit, apiKey: string): Promise<Response> {
|
|
27
|
-
const headers = {
|
|
28
|
-
'x-api-key': apiKey,
|
|
29
|
-
Accept: 'application/json',
|
|
30
|
-
...(init.body ? { 'Content-Type': 'application/json' } : {}),
|
|
31
|
-
...(init.headers || {}),
|
|
32
|
-
} as Record<string, string>
|
|
33
|
-
let res: Response | null = null
|
|
34
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
35
|
-
res = await fetch(`${API()}${path}`, { ...init, headers })
|
|
36
|
-
if (res.status < 500) return res
|
|
37
|
-
if (attempt < 2) await new Promise((r) => setTimeout(r, 1500 * (attempt + 1)))
|
|
38
|
-
}
|
|
39
|
-
return res!
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function readBody(res: Response): Promise<Any> {
|
|
43
|
-
const text = await res.text().catch(() => '')
|
|
44
|
-
try { return JSON.parse(text) } catch { return text || null }
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const REMOTE_STATUS: Record<string, string> = {
|
|
48
|
-
CONCLUIDO: 'authorized', AUTORIZADO: 'authorized', REJEITADO: 'rejected', DENEGADO: 'rejected',
|
|
49
|
-
CANCELADO: 'cancelled', SUBSTITUIDO: 'substituted', PROCESSANDO: 'processing', PENDENTE: 'processing',
|
|
50
|
-
AGENDADO: 'processing', AGUARDANDO_CANCELAMENTO: 'processing',
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function remoteStatus(body: Any): string {
|
|
54
|
-
const first = Array.isArray(body) ? body[0] : body
|
|
55
|
-
return String(first?.situacao || first?.status || '').toUpperCase()
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** PlugNotas sends authorization dates as dd/MM/yyyy as well as ISO. */
|
|
59
|
-
function parseDate(v: unknown): string | null {
|
|
60
|
-
if (!v) return null
|
|
61
|
-
const s = String(v)
|
|
62
|
-
const br = s.match(/^(\d{2})\/(\d{2})\/(\d{4})/)
|
|
63
|
-
const d = br ? new Date(`${br[3]}-${br[2]}-${br[1]}T00:00:00-03:00`) : new Date(s)
|
|
64
|
-
return Number.isNaN(d.getTime()) ? null : d.toISOString()
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function consultPath(note: Any): string {
|
|
68
|
-
return note.model === 'nfse'
|
|
69
|
-
? `/nfse/consultar/${note.provider_document_id}`
|
|
70
|
-
: `/${note.model}/${note.provider_document_id}/resumo`
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** One consult, turned into the patch `fiscalbr_note_apply` accepts. */
|
|
74
|
-
async function consult(note: Any, apiKey: string): Promise<{ patch: Any; remote: string; body: Any }> {
|
|
75
|
-
const res = await pnFetch(consultPath(note), { method: 'GET' }, apiKey)
|
|
76
|
-
const body = await readBody(res)
|
|
77
|
-
const remote = res.ok ? remoteStatus(body) : ''
|
|
78
|
-
const first = Array.isArray(body) ? body[0] : body
|
|
79
|
-
const patch: Any = { status_payload: body }
|
|
80
|
-
let status = REMOTE_STATUS[remote]
|
|
81
|
-
|
|
82
|
-
// A requested cancellation keeps answering CONCLUIDO until the city hall
|
|
83
|
-
// finishes; showing "Autorizada" then would hide the cancellation in flight.
|
|
84
|
-
if (note.cancelled_at && status === 'authorized') status = 'processing'
|
|
85
|
-
// The cancellation itself was refused: the note is valid again.
|
|
86
|
-
if (note.cancelled_at && remote && (remote === 'REJEITADO' || remote === 'DENEGADO')) {
|
|
87
|
-
status = 'authorized'
|
|
88
|
-
patch.cancelled_at = null
|
|
89
|
-
patch.cancel_reason = null
|
|
90
|
-
} else if (remote === 'REJEITADO' || remote === 'DENEGADO') {
|
|
91
|
-
patch.rejection_payload = body
|
|
92
|
-
}
|
|
93
|
-
if (status) patch.status = status
|
|
94
|
-
|
|
95
|
-
const number = first?.numero || first?.numeroNfse || first?.nfse?.numero
|
|
96
|
-
const series = first?.serie ?? first?.nfse?.serie
|
|
97
|
-
const key = first?.chave || first?.chaveAcesso || first?.protocolo?.chave
|
|
98
|
-
const code = first?.codigoVerificacao || first?.nfse?.codigoVerificacao
|
|
99
|
-
if (number) patch.number = String(number)
|
|
100
|
-
if (series != null) patch.series = String(series)
|
|
101
|
-
if (key) patch.access_key = String(key)
|
|
102
|
-
if (code) patch.verification_code = String(code)
|
|
103
|
-
if (status === 'authorized' && !note.cancelled_at) {
|
|
104
|
-
const at = parseDate(first?.dataAutorizacao || first?.emissao || first?.dataEmissao)
|
|
105
|
-
if (at) patch.authorized_at = at
|
|
106
|
-
}
|
|
107
|
-
return { patch, remote, body }
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function isPdf(b: Uint8Array) {
|
|
111
|
-
return b.length > 4 && b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
Deno.serve(async (req) => {
|
|
115
|
-
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
|
116
|
-
try {
|
|
117
|
-
const apiKey = Deno.env.get('PLUGNOTAS_API_KEY')
|
|
118
|
-
if (!apiKey) return json({ error: 'O emissor fiscal não está configurado neste ambiente.' }, 503)
|
|
119
|
-
const auth = req.headers.get('Authorization') || ''
|
|
120
|
-
if (!auth) return json({ error: 'Unauthorized' }, 401)
|
|
121
|
-
const body: Any = await req.json().catch(() => ({}))
|
|
122
|
-
// A user with two accounts has no tenant without this; it only chooses among
|
|
123
|
-
// the caller's own memberships (app.requested_tenant), never widens.
|
|
124
|
-
const tenantId = typeof body?.tenantId === 'string' ? body.tenantId : ''
|
|
125
|
-
const db = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!, {
|
|
126
|
-
global: { headers: { Authorization: auth, ...(tenantId ? { 'x-fayz-tenant': tenantId } : {}) } },
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
const action = String(body?.action || '')
|
|
130
|
-
const documentId = String(body?.document_id || '')
|
|
131
|
-
if (!['poll_status', 'cancel', 'get_pdf'].includes(action) || !documentId) {
|
|
132
|
-
return json({ error: 'Pedido inválido.' }, 400)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const gate = action === 'cancel' ? 'cancel' : 'poll'
|
|
136
|
-
const { data: note, error: noteError } = await db.rpc('fiscalbr_note_for_provider', {
|
|
137
|
-
p_document_id: documentId, p_action: gate,
|
|
138
|
-
})
|
|
139
|
-
if (noteError) {
|
|
140
|
-
const denied = noteError.code === '42501'
|
|
141
|
-
return json({ error: denied ? 'Sem permissão para esta nota.' : noteError.message }, denied ? 403 : 404)
|
|
142
|
-
}
|
|
143
|
-
if (!note?.provider_document_id) return json({ error: 'Esta nota não tem registro no emissor fiscal.' }, 422)
|
|
144
|
-
|
|
145
|
-
const apply = async (patch: Any, event: Any | null) => {
|
|
146
|
-
const { error } = await db.rpc('fiscalbr_note_apply', {
|
|
147
|
-
p_document_id: documentId, p_action: action === 'cancel' ? 'cancel' : 'poll', p_patch: patch, p_event: event,
|
|
148
|
-
})
|
|
149
|
-
if (error) throw new Error(error.message)
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (action === 'poll_status') {
|
|
153
|
-
const { patch, remote } = await consult(note, apiKey)
|
|
154
|
-
await apply(patch, { event_type: 'consulta', status: remote || null })
|
|
155
|
-
return json({ ok: true, status: patch.status ?? note.status })
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
if (action === 'get_pdf') {
|
|
159
|
-
if (!['authorized', 'cancelled'].includes(note.status)) {
|
|
160
|
-
return json({ error: 'O PDF só existe para nota autorizada.' }, 422)
|
|
161
|
-
}
|
|
162
|
-
const path = note.model === 'nfse'
|
|
163
|
-
? `/nfse/pdf/${note.provider_document_id}`
|
|
164
|
-
: `/${note.model}/${note.provider_document_id}/pdf`
|
|
165
|
-
const res = await pnFetch(path, { method: 'GET', headers: { Accept: 'application/pdf,application/json' } }, apiKey)
|
|
166
|
-
let bytes = new Uint8Array(await res.arrayBuffer())
|
|
167
|
-
// Some city halls hand back a link instead of the bytes.
|
|
168
|
-
if (res.ok && !isPdf(bytes)) {
|
|
169
|
-
try {
|
|
170
|
-
const j = JSON.parse(new TextDecoder().decode(bytes))
|
|
171
|
-
const link = [j?.url, j?.link, j?.pdf, j?.data?.url, j?.data?.link, j?.data?.pdf]
|
|
172
|
-
.find((v: unknown) => typeof v === 'string' && /^https?:\/\//.test(v))
|
|
173
|
-
if (link) {
|
|
174
|
-
const r2 = await fetch(link)
|
|
175
|
-
if (r2.ok) bytes = new Uint8Array(await r2.arrayBuffer())
|
|
176
|
-
}
|
|
177
|
-
} catch { /* falls through to the error below */ }
|
|
178
|
-
}
|
|
179
|
-
if (!isPdf(bytes)) return json({ error: 'O emissor ainda não devolveu o PDF desta nota.' }, 502)
|
|
180
|
-
// octet-stream: supabase-js hands that back as a Blob; application/pdf would arrive as text.
|
|
181
|
-
return new Response(bytes, { headers: { ...corsHeaders, 'Content-Type': 'application/octet-stream' } })
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// cancel
|
|
185
|
-
if (note.status !== 'authorized' || note.cancelled_at) {
|
|
186
|
-
return json({ error: 'Só é possível cancelar uma nota autorizada.' }, 422)
|
|
187
|
-
}
|
|
188
|
-
const reason = String(body?.justification || '').trim()
|
|
189
|
-
const min = note.model === 'nfce' ? 15 : 10
|
|
190
|
-
if (reason.length < min) return json({ error: `A justificativa precisa de pelo menos ${min} caracteres.` }, 422)
|
|
191
|
-
if (reason.length > 255) return json({ error: 'A justificativa pode ter no máximo 255 caracteres.' }, 422)
|
|
192
|
-
|
|
193
|
-
const isNfse = note.model === 'nfse'
|
|
194
|
-
const res = await pnFetch(
|
|
195
|
-
isNfse ? `/nfse/cancelar/${note.provider_document_id}` : `/${note.model}/${note.provider_document_id}/cancelamento`,
|
|
196
|
-
{ method: 'POST', body: JSON.stringify(isNfse ? { codigo: String(body?.code || '2'), motivo: reason } : { justificativa: reason }) },
|
|
197
|
-
apiKey,
|
|
198
|
-
)
|
|
199
|
-
const answer = await readBody(res)
|
|
200
|
-
if (!res.ok) {
|
|
201
|
-
const message = String(answer?.error?.message || answer?.message || `HTTP ${res.status}`)
|
|
202
|
-
await apply({}, { event_type: 'cancelamento', status: 'ERRO', justification: reason, response_payload: answer })
|
|
203
|
-
return json({
|
|
204
|
-
error: /prazo|30 min|tempo/i.test(message)
|
|
205
|
-
? `Cancelamento fora do prazo permitido pela SEFAZ/prefeitura: ${message}`
|
|
206
|
-
: message,
|
|
207
|
-
}, 422)
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Accepted, not done: the city hall answers "em processamento" and concludes later.
|
|
211
|
-
const now = new Date().toISOString()
|
|
212
|
-
await apply(
|
|
213
|
-
{ status: 'processing', cancelled_at: now, cancel_reason: reason },
|
|
214
|
-
{ event_type: 'cancelamento', status: 'SOLICITADO', justification: reason, response_payload: answer },
|
|
215
|
-
)
|
|
216
|
-
const { patch, remote } = await consult({ ...note, cancelled_at: now }, apiKey)
|
|
217
|
-
await apply(patch, { event_type: 'consulta', status: remote || null })
|
|
218
|
-
return json({ ok: true, status: patch.status ?? 'processing' })
|
|
219
|
-
} catch (e) {
|
|
220
|
-
return json({ error: e instanceof Error ? e.message : String(e) }, 500)
|
|
221
|
-
}
|
|
222
|
-
})
|