@fayz-ai/plugin-financial 0.8.3 → 0.8.4

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.
@@ -1,4 +1,4 @@
1
- // AUTO-GENERATED from 000_plg_rename.sql, 001_financial_base.sql, 002_chart_of_accounts.sql, 003_card_brands.sql, 004_order_to_cash.sql, 005_seed_defaults.sql, 006_rls_policies.sql, 006b_extract_fee_amount.sql, 007_reconciliation.sql, 007b_movement_payment_method_type.sql, 008_split_payment_movements.sql, 009_agent_rpcs.sql, 010_invoice_internal.sql — regenerate with scripts/embed-migrations.mjs
1
+ // AUTO-GENERATED from 000_plg_rename.sql, 001_financial_base.sql, 002_chart_of_accounts.sql, 003_card_brands.sql, 004_order_to_cash.sql, 005_seed_defaults.sql, 006_rls_policies.sql, 006b_extract_fee_amount.sql, 007_reconciliation.sql, 007b_movement_payment_method_type.sql, 008_split_payment_movements.sql, 009_agent_rpcs.sql, 010_invoice_internal.sql, 011_nfe.sql — regenerate with scripts/embed-migrations.mjs
2
2
  // SQL files are the source of truth; this inline copy lets the manifest declare
3
3
  // migrations as data. Do not edit by hand — run the embed script instead.
4
4
 
@@ -845,6 +845,66 @@ GRANT EXECUTE ON FUNCTION public.fn_invoice_from_order(uuid, date, text, int, te
845
845
  NOTIFY pgrst, 'reload schema';
846
846
  `
847
847
 
848
+ export const MIGRATION_011_NFE = `-- 011_nfe.sql — Nota Fiscal Eletrônica (NF-e / NFC-e / NFS-e)
849
+ -- ----------------------------------------------------------------------------
850
+ -- Fiscal documents the merchant issues, tracked against orders. This migration
851
+ -- ships the STRUCTURE only: the actual authorization with SEFAZ / the prefeitura
852
+ -- is delegated to an external emissor (Focus NFe, NFe.io, PlugNotas…) wired
853
+ -- later behind the provider's issueNota. Rows here carry the número/série,
854
+ -- status, access key and the links to the authorized XML + DANFE once emitted.
855
+ --
856
+ -- Per-tenant RLS, canonical form (tenant_id IN user_tenant_ids()). Idempotent.
857
+
858
+ CREATE TABLE IF NOT EXISTS public.plg_financial_nfe (
859
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
860
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
861
+ -- nfe (55, mercadoria) | nfce (65, consumidor) | nfse (serviço)
862
+ model text NOT NULL DEFAULT 'nfe',
863
+ -- draft | processing | issued | cancelled | error
864
+ status text NOT NULL DEFAULT 'draft',
865
+ number text,
866
+ series text,
867
+ order_id uuid,
868
+ contact_id uuid,
869
+ contact_name text,
870
+ total_amount numeric(14,2) NOT NULL DEFAULT 0,
871
+ issued_at timestamptz,
872
+ access_key text,
873
+ xml_url text,
874
+ danfe_url text,
875
+ emissor text,
876
+ error_message text,
877
+ observations text,
878
+ metadata jsonb NOT NULL DEFAULT '{}',
879
+ created_at timestamptz NOT NULL DEFAULT now(),
880
+ updated_at timestamptz NOT NULL DEFAULT now()
881
+ );
882
+
883
+ CREATE INDEX IF NOT EXISTS idx_plg_financial_nfe_tenant ON public.plg_financial_nfe(tenant_id);
884
+ CREATE INDEX IF NOT EXISTS idx_plg_financial_nfe_order ON public.plg_financial_nfe(order_id);
885
+
886
+ ALTER TABLE public.plg_financial_nfe ENABLE ROW LEVEL SECURITY;
887
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_financial_nfe TO authenticated;
888
+
889
+ DO $$
890
+ BEGIN
891
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='plg_financial_nfe' AND policyname='plg_financial_nfe_select') THEN
892
+ CREATE POLICY plg_financial_nfe_select ON public.plg_financial_nfe FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
893
+ END IF;
894
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='plg_financial_nfe' AND policyname='plg_financial_nfe_insert') THEN
895
+ CREATE POLICY plg_financial_nfe_insert ON public.plg_financial_nfe FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
896
+ END IF;
897
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='plg_financial_nfe' AND policyname='plg_financial_nfe_update') THEN
898
+ CREATE POLICY plg_financial_nfe_update ON public.plg_financial_nfe FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
899
+ END IF;
900
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='plg_financial_nfe' AND policyname='plg_financial_nfe_delete') THEN
901
+ CREATE POLICY plg_financial_nfe_delete ON public.plg_financial_nfe FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
902
+ END IF;
903
+ END $$;
904
+
905
+ NOTIFY pgrst, 'reload schema';
906
+ `
907
+
848
908
  export const MIGRATIONS: Array<{ id: string; sql: string }> = [
849
909
  { id: "000_plg_rename", sql: MIGRATION_000_PLG_RENAME },
850
910
  { id: "001_financial_base", sql: MIGRATION_001_FINANCIAL_BASE },
@@ -859,4 +919,5 @@ export const MIGRATIONS: Array<{ id: string; sql: string }> = [
859
919
  { id: "008_split_payment_movements", sql: MIGRATION_008_SPLIT_PAYMENT_MOVEMENTS },
860
920
  { id: "009_agent_rpcs", sql: MIGRATION_009_AGENT_RPCS },
861
921
  { id: "010_invoice_internal", sql: MIGRATION_010_INVOICE_INTERNAL },
922
+ { id: "011_nfe", sql: MIGRATION_011_NFE },
862
923
  ]
package/src/types.ts CHANGED
@@ -339,6 +339,8 @@ export interface MovementQuery {
339
339
  status?: MovementStatus | MovementStatus[]
340
340
  bankAccountId?: string
341
341
  cashSessionId?: string
342
+ /** Scope to a single invoice's movements (bills + payments). */
343
+ invoiceId?: string
342
344
  dateRange?: DateRange
343
345
  page?: number
344
346
  pageSize?: number
@@ -527,3 +529,64 @@ export interface ReconcileInput {
527
529
  /** Internal movement to link; omit to accept the line standalone */
528
530
  matchedMovementId?: string
529
531
  }
532
+
533
+ // ============================================================
534
+ // NOTA FISCAL ELETRÔNICA (NF-e / NFC-e / NFS-e)
535
+ // ------------------------------------------------------------
536
+ // Structure + UI live here now; actual authorization (SEFAZ / prefeitura) is
537
+ // delegated to an external emissor (Focus NFe, NFe.io, PlugNotas…) behind the
538
+ // provider's `issueNota`. Until an emissor is wired, `issueNota` assigns a
539
+ // number/série locally and marks the nota `issued` — enough to track fiscal
540
+ // documents against orders without the emission integration.
541
+ // ============================================================
542
+
543
+ /** 55 = NF-e (mercadoria), 65 = NFC-e (consumidor), nfse = serviço (prefeitura). */
544
+ export type NotaFiscalModel = 'nfe' | 'nfce' | 'nfse'
545
+ export type NotaFiscalStatus = 'draft' | 'processing' | 'issued' | 'cancelled' | 'error'
546
+
547
+ export interface NotaFiscal {
548
+ id: string
549
+ tenantId: string
550
+ model: NotaFiscalModel
551
+ status: NotaFiscalStatus
552
+ /** Número da nota — null while draft (assigned on issue). */
553
+ number: string | null
554
+ series: string | null
555
+ /** Order this nota documents (plg_shop_orders / orders id), if any. */
556
+ orderId?: string
557
+ contactId?: string
558
+ contactName?: string
559
+ totalAmount: number
560
+ issuedAt?: string
561
+ /** Chave de acesso (44 dígitos), when authorized. */
562
+ accessKey?: string
563
+ /** Links to the authorized XML and the DANFE/DANFCE PDF. */
564
+ xmlUrl?: string
565
+ danfeUrl?: string
566
+ /** External emissor id (focusnfe | nfeio | plugnotas | manual). */
567
+ emissor?: string
568
+ errorMessage?: string
569
+ observations?: string
570
+ metadata?: Record<string, unknown>
571
+ createdAt: string
572
+ updatedAt: string
573
+ }
574
+
575
+ export interface CreateNotaFiscalInput {
576
+ model?: NotaFiscalModel
577
+ orderId?: string
578
+ contactId?: string
579
+ contactName?: string
580
+ totalAmount: number
581
+ series?: string
582
+ observations?: string
583
+ }
584
+
585
+ export interface NotaFiscalQuery {
586
+ status?: NotaFiscalStatus | NotaFiscalStatus[]
587
+ model?: NotaFiscalModel
588
+ orderId?: string
589
+ search?: string
590
+ page?: number
591
+ pageSize?: number
592
+ }
@@ -60,7 +60,9 @@ export function InvoiceDetailView({ invoiceId, direction, onBack, onEdit, backLa
60
60
  const [inv, invItems, movResult] = await Promise.all([
61
61
  provider.getInvoiceById(invoiceId),
62
62
  provider.getInvoiceItems(invoiceId),
63
- provider.getMovements({ direction }),
63
+ // Scope to THIS invoice (not the tenant-wide first-50 page) so every
64
+ // installment/payment loads regardless of how many movements exist.
65
+ provider.getMovements({ direction, invoiceId, pageSize: 500 }),
64
66
  ])
65
67
  setInvoice(inv)
66
68
  setItems(invItems)
@@ -0,0 +1,209 @@
1
+ import React, { useCallback, useEffect, useState } from 'react'
2
+ import { FileText, Plus, Send, Ban, ExternalLink, FileCheck2 } from 'lucide-react'
3
+ import { SubpageHeader, Button, Input, Skeleton } from '@fayz-ai/ui'
4
+ import { PermissionGate } from '@fayz-ai/saas'
5
+ import { useFinancialProvider, useFinancialConfig, formatCurrency } from '../FinancialContext'
6
+ import type { NotaFiscal, NotaFiscalModel, NotaFiscalStatus, CreateNotaFiscalInput } from '../types'
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Notas Fiscais — fiscal-document tracking (NF-e / NFC-e / NFS-e). Structure +
10
+ // UI; issuing is delegated (provider.issueNota assigns número/série locally
11
+ // until a SEFAZ emissor is wired). Draft → Emitir → Emitida, with cancel and
12
+ // links to the authorized XML / DANFE once an emissor populates them.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const MODEL_LABEL: Record<NotaFiscalModel, string> = {
16
+ nfe: 'NF-e', nfce: 'NFC-e', nfse: 'NFS-e',
17
+ }
18
+
19
+ const STATUS_META: Record<NotaFiscalStatus, { label: string; className: string }> = {
20
+ draft: { label: 'Rascunho', className: 'bg-muted text-muted-foreground' },
21
+ processing: { label: 'Processando', className: 'bg-blue-100 text-blue-800' },
22
+ issued: { label: 'Emitida', className: 'bg-emerald-100 text-emerald-800' },
23
+ cancelled: { label: 'Cancelada', className: 'bg-red-100 text-red-700' },
24
+ error: { label: 'Erro', className: 'bg-orange-100 text-orange-700' },
25
+ }
26
+
27
+ export function NotasFiscaisView() {
28
+ const provider = useFinancialProvider()
29
+ const { currency } = useFinancialConfig()
30
+ const [notas, setNotas] = useState<NotaFiscal[]>([])
31
+ const [loading, setLoading] = useState(true)
32
+ const [creating, setCreating] = useState(false)
33
+ const [busy, setBusy] = useState<string | null>(null)
34
+
35
+ const supported = typeof provider.getNotas === 'function'
36
+
37
+ const reload = useCallback(async () => {
38
+ if (!provider.getNotas) { setLoading(false); return }
39
+ setLoading(true)
40
+ try {
41
+ const res = await provider.getNotas({ pageSize: 100 })
42
+ setNotas(res.data)
43
+ } finally {
44
+ setLoading(false)
45
+ }
46
+ }, [provider])
47
+
48
+ useEffect(() => { void reload() }, [reload])
49
+
50
+ const create = async (input: CreateNotaFiscalInput) => {
51
+ if (!provider.createNota) return
52
+ await provider.createNota(input)
53
+ setCreating(false)
54
+ await reload()
55
+ }
56
+
57
+ const issue = async (id: string) => {
58
+ if (!provider.issueNota) return
59
+ setBusy(id)
60
+ try { await provider.issueNota(id); await reload() } finally { setBusy(null) }
61
+ }
62
+
63
+ const cancel = async (id: string) => {
64
+ if (!provider.cancelNota) return
65
+ setBusy(id)
66
+ try { await provider.cancelNota(id); await reload() } finally { setBusy(null) }
67
+ }
68
+
69
+ if (!supported) {
70
+ return (
71
+ <div className="p-1">
72
+ <SubpageHeader title="Notas Fiscais" subtitle="Emissão e acompanhamento de NF-e / NFC-e / NFS-e" icon="FileText" />
73
+ <p className="mt-4 text-sm text-muted-foreground">Este provedor financeiro ainda não expõe notas fiscais.</p>
74
+ </div>
75
+ )
76
+ }
77
+
78
+ return (
79
+ <div className="p-1">
80
+ <SubpageHeader
81
+ title="Notas Fiscais"
82
+ subtitle="Emissão e acompanhamento de NF-e / NFC-e / NFS-e"
83
+ icon="FileText"
84
+ actions={
85
+ <PermissionGate feature="financial" action="create">
86
+ <Button size="sm" onClick={() => setCreating(true)}>
87
+ <Plus className="mr-1.5 h-4 w-4" /> Nova nota
88
+ </Button>
89
+ </PermissionGate>
90
+ }
91
+ />
92
+
93
+ <div className="mt-3 rounded-md border border-amber-300/50 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
94
+ Emissão fiscal (SEFAZ) ainda não integrada — "Emitir" atribui número/série localmente para acompanhamento. A integração com o emissor entra sem mudar esta tela.
95
+ </div>
96
+
97
+ {creating && <CreateForm onCancel={() => setCreating(false)} onCreate={create} />}
98
+
99
+ {loading ? (
100
+ <div className="mt-4 space-y-2">
101
+ {Array.from({ length: 4 }).map((_, i) => (
102
+ <div key={i} className="flex items-center gap-3 rounded-card border border-border bg-card p-3">
103
+ <Skeleton className="h-9 w-9 shrink-0 rounded-full" />
104
+ <div className="min-w-0 flex-1 space-y-2">
105
+ <Skeleton className="h-3.5 w-40 rounded" />
106
+ <Skeleton className="h-3 w-56 rounded" />
107
+ </div>
108
+ <Skeleton className="h-8 w-20 shrink-0 rounded-md" />
109
+ </div>
110
+ ))}
111
+ </div>
112
+ ) : notas.length === 0 ? (
113
+ <div className="mt-4 rounded-card border border-dashed border-border p-8 text-center">
114
+ <FileText className="mx-auto h-8 w-8 text-muted-foreground" />
115
+ <p className="mt-2 text-sm font-medium text-foreground">Nenhuma nota fiscal ainda</p>
116
+ <p className="mt-1 text-xs text-muted-foreground">Crie uma nota a partir de um pedido ou manualmente.</p>
117
+ </div>
118
+ ) : (
119
+ <div className="mt-4 space-y-2">
120
+ {notas.map((n) => {
121
+ const meta = STATUS_META[n.status]
122
+ return (
123
+ <div key={n.id} className="flex items-center gap-3 rounded-card border border-border bg-card p-3">
124
+ <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10">
125
+ <FileText className="h-4 w-4 text-primary" />
126
+ </div>
127
+ <div className="min-w-0 flex-1">
128
+ <div className="flex items-center gap-2">
129
+ <span className="text-sm font-medium text-foreground">
130
+ {MODEL_LABEL[n.model]} {n.number ? `#${n.number}` : '(rascunho)'}
131
+ </span>
132
+ <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${meta.className}`}>{meta.label}</span>
133
+ </div>
134
+ <p className="truncate text-xs text-muted-foreground">
135
+ {n.contactName || 'Sem destinatário'} · {formatCurrency(n.totalAmount, currency)}
136
+ {n.issuedAt ? ` · ${n.issuedAt.slice(0, 10)}` : ''}
137
+ </p>
138
+ </div>
139
+ <div className="flex shrink-0 items-center gap-1">
140
+ {n.danfeUrl && (
141
+ <Button size="sm" variant="ghost" onClick={() => window.open(n.danfeUrl!, '_blank', 'noopener,noreferrer')} title="DANFE">
142
+ <FileCheck2 className="h-4 w-4" />
143
+ </Button>
144
+ )}
145
+ {n.xmlUrl && (
146
+ <Button size="sm" variant="ghost" onClick={() => window.open(n.xmlUrl!, '_blank', 'noopener,noreferrer')} title="XML">
147
+ <ExternalLink className="h-4 w-4" />
148
+ </Button>
149
+ )}
150
+ {n.status === 'draft' && (
151
+ <PermissionGate feature="financial" action="edit">
152
+ <Button size="sm" disabled={busy === n.id} onClick={() => issue(n.id)}>
153
+ <Send className="mr-1.5 h-3.5 w-3.5" /> Emitir
154
+ </Button>
155
+ </PermissionGate>
156
+ )}
157
+ {n.status === 'issued' && (
158
+ <PermissionGate feature="financial" action="edit">
159
+ <Button size="sm" variant="ghost" disabled={busy === n.id} onClick={() => cancel(n.id)} title="Cancelar">
160
+ <Ban className="h-4 w-4 text-red-500" />
161
+ </Button>
162
+ </PermissionGate>
163
+ )}
164
+ </div>
165
+ </div>
166
+ )
167
+ })}
168
+ </div>
169
+ )}
170
+ </div>
171
+ )
172
+ }
173
+
174
+ function CreateForm({ onCancel, onCreate }: { onCancel: () => void; onCreate: (input: CreateNotaFiscalInput) => void }) {
175
+ const [model, setModel] = useState<NotaFiscalModel>('nfe')
176
+ const [contactName, setContactName] = useState('')
177
+ const [total, setTotal] = useState('')
178
+ const [observations, setObservations] = useState('')
179
+
180
+ const models: NotaFiscalModel[] = ['nfe', 'nfce', 'nfse']
181
+
182
+ return (
183
+ <div className="mt-4 rounded-card border border-border bg-card p-4">
184
+ <p className="mb-3 text-sm font-semibold text-foreground">Nova nota fiscal</p>
185
+ <div className="mb-3 flex flex-wrap gap-2">
186
+ {models.map((m) => (
187
+ <button key={m} type="button" onClick={() => setModel(m)} className={`rounded-full px-3 py-1 text-xs font-medium ${m === model ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground'}`}>
188
+ {MODEL_LABEL[m]}
189
+ </button>
190
+ ))}
191
+ </div>
192
+ <div className="flex flex-col gap-2 sm:flex-row">
193
+ <Input value={contactName} placeholder="Destinatário" onChange={(e) => setContactName(e.target.value)} className="flex-1" />
194
+ <Input value={total} placeholder="Valor total" inputMode="decimal" onChange={(e) => setTotal(e.target.value)} className="sm:w-40" />
195
+ </div>
196
+ <Input value={observations} placeholder="Observações (opcional)" onChange={(e) => setObservations(e.target.value)} className="mt-2" />
197
+ <div className="mt-3 flex gap-2">
198
+ <Button
199
+ size="sm"
200
+ disabled={!total || Number.isNaN(Number(total))}
201
+ onClick={() => onCreate({ model, contactName: contactName || undefined, totalAmount: Number(total) || 0, observations: observations || undefined })}
202
+ >
203
+ Criar rascunho
204
+ </Button>
205
+ <Button size="sm" variant="outline" onClick={onCancel}>Cancelar</Button>
206
+ </div>
207
+ </div>
208
+ )
209
+ }