@fayz-ai/plugin-financial 0.1.2 → 0.1.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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/dist/FinancialContext.d.ts +2 -0
  3. package/dist/FinancialContext.d.ts.map +1 -1
  4. package/dist/FinancialPage.d.ts.map +1 -1
  5. package/dist/components/PaymentModal.d.ts.map +1 -1
  6. package/dist/components/PersonStatementWidget.d.ts +18 -0
  7. package/dist/components/PersonStatementWidget.d.ts.map +1 -0
  8. package/dist/components/TransferModal.d.ts +7 -0
  9. package/dist/components/TransferModal.d.ts.map +1 -0
  10. package/dist/contract.test.d.ts +2 -0
  11. package/dist/contract.test.d.ts.map +1 -0
  12. package/dist/data/capability.test.d.ts +2 -0
  13. package/dist/data/capability.test.d.ts.map +1 -0
  14. package/dist/data/mock.d.ts.map +1 -1
  15. package/dist/data/supabase.d.ts.map +1 -1
  16. package/dist/data/types.d.ts +13 -2
  17. package/dist/data/types.d.ts.map +1 -1
  18. package/dist/index.cjs +1529 -263
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.ts +2 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1437 -171
  23. package/dist/index.js.map +1 -1
  24. package/dist/locales/en.d.ts.map +1 -1
  25. package/dist/locales/pt-BR.d.ts.map +1 -1
  26. package/dist/registries.d.ts.map +1 -1
  27. package/dist/store.d.ts +4 -2
  28. package/dist/store.d.ts.map +1 -1
  29. package/dist/types.d.ts +111 -1
  30. package/dist/types.d.ts.map +1 -1
  31. package/dist/views/InvoiceDetailView.d.ts +3 -1
  32. package/dist/views/InvoiceDetailView.d.ts.map +1 -1
  33. package/dist/views/InvoiceFormView.d.ts.map +1 -1
  34. package/dist/views/PayablesView.d.ts +5 -1
  35. package/dist/views/PayablesView.d.ts.map +1 -1
  36. package/dist/views/ReceivablesView.d.ts +5 -1
  37. package/dist/views/ReceivablesView.d.ts.map +1 -1
  38. package/dist/views/ReconciliationView.d.ts +3 -0
  39. package/dist/views/ReconciliationView.d.ts.map +1 -0
  40. package/dist/views/StatementsView.d.ts +3 -1
  41. package/dist/views/StatementsView.d.ts.map +1 -1
  42. package/dist/views/navReferrer.d.ts +5 -0
  43. package/dist/views/navReferrer.d.ts.map +1 -0
  44. package/package.json +8 -5
  45. package/src/FinancialContext.tsx +2 -0
  46. package/src/FinancialPage.tsx +32 -17
  47. package/src/components/PaymentModal.tsx +58 -11
  48. package/src/components/PersonStatementWidget.tsx +133 -0
  49. package/src/components/TransferModal.tsx +150 -0
  50. package/src/contract.test.ts +58 -0
  51. package/src/data/capability.test.ts +90 -0
  52. package/src/data/mock.ts +265 -52
  53. package/src/data/supabase.ts +359 -30
  54. package/src/data/types.ts +22 -3
  55. package/src/index.ts +28 -2
  56. package/src/locales/en.ts +76 -0
  57. package/src/locales/pt-BR.ts +76 -0
  58. package/src/migrations/006_extract_fee_amount.sql +20 -0
  59. package/src/migrations/006_rls_policies.sql +37 -0
  60. package/src/migrations/007_reconciliation.sql +32 -0
  61. package/src/migrations/008_split_payment_movements.sql +29 -0
  62. package/src/registries.ts +3 -1
  63. package/src/store.ts +27 -7
  64. package/src/types.ts +126 -1
  65. package/src/views/InvoiceDetailView.tsx +70 -51
  66. package/src/views/InvoiceFormView.tsx +55 -20
  67. package/src/views/PayablesView.tsx +13 -2
  68. package/src/views/ReceivablesView.tsx +13 -2
  69. package/src/views/ReconciliationView.tsx +211 -0
  70. package/src/views/StatementsView.tsx +241 -29
  71. package/src/views/navReferrer.ts +24 -0
@@ -1,40 +1,115 @@
1
1
  import React, { useEffect, useState, useMemo } from 'react'
2
- import { Receipt } from 'lucide-react'
2
+ import { Receipt, ArrowRightLeft, CheckCheck, Landmark } from 'lucide-react'
3
3
  import type { ColumnDef } from '@tanstack/react-table'
4
- import { DataTable } from '@fayz-ai/ui'
4
+ import { DataTable, SubpageHeader, DatePicker, Button, Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@fayz-ai/ui'
5
5
  import { useFinancialConfig, useFinancialStore, formatCurrency } from '../FinancialContext'
6
- import { SubpageHeader } from '@fayz-ai/ui'
7
- import { DatePicker } from '@fayz-ai/ui'
8
6
  import { useTranslation } from '@fayz-ai/core'
7
+ import { PersonLink } from '@fayz-ai/saas'
9
8
  import type { StatementEntry } from '../types'
9
+ import { TransferModal } from '../components/TransferModal'
10
10
 
11
- export function StatementsView() {
11
+ const ALL = 'all'
12
+
13
+ // Soft reconciliation marker for an extract row — a muted glyph with a hover
14
+ // tooltip explaining what it means. Rendered only when the movement carries the
15
+ // relevant state (reconciledAt / externalSource).
16
+ function ReconcileMarker({ kind, label }: { kind: 'reconciled' | 'imported'; label: string }) {
17
+ return (
18
+ <TooltipProvider delayDuration={150}>
19
+ <Tooltip>
20
+ <TooltipTrigger asChild>
21
+ <span className="inline-flex shrink-0 cursor-default">
22
+ {kind === 'reconciled'
23
+ ? <CheckCheck className="h-3 w-3 text-success/70" />
24
+ : <Landmark className="h-3 w-3 text-muted-foreground/40" />}
25
+ </span>
26
+ </TooltipTrigger>
27
+ <TooltipContent className="text-xs">{label}</TooltipContent>
28
+ </Tooltip>
29
+ </TooltipProvider>
30
+ )
31
+ }
32
+
33
+ // Reference to the internal transaction a reconciled bank line was matched to —
34
+ // a small "⇄ {ref}" chip, clickable when the counterpart has an invoice to open.
35
+ function ReconciledWithRef({ refLabel, tooltip, onOpen }: { refLabel: string; tooltip: string; onOpen?: () => void }) {
36
+ const inner = (
37
+ <span className="inline-flex items-center gap-0.5 text-[10px] text-muted-foreground max-w-[160px]">
38
+ <ArrowRightLeft className="h-2.5 w-2.5 shrink-0" />
39
+ <span className="truncate">{refLabel}</span>
40
+ </span>
41
+ )
42
+ return (
43
+ <TooltipProvider delayDuration={150}>
44
+ <Tooltip>
45
+ <TooltipTrigger asChild>
46
+ {onOpen
47
+ ? <button onClick={onOpen} className="hover:text-primary transition-colors">{inner}</button>
48
+ : <span className="cursor-default">{inner}</span>}
49
+ </TooltipTrigger>
50
+ <TooltipContent className="text-xs">{tooltip}</TooltipContent>
51
+ </Tooltip>
52
+ </TooltipProvider>
53
+ )
54
+ }
55
+
56
+ export function StatementsView({ onNavigate }: { onNavigate?: (view: string) => void }) {
12
57
  const t = useTranslation()
13
58
  const { currency } = useFinancialConfig()
14
59
  const bankAccounts = useFinancialStore((s) => s.bankAccounts)
15
- const statementEntries = useFinancialStore((s) => s.statementEntries)
60
+ const statement = useFinancialStore((s) => s.statement)
16
61
  const statementLoading = useFinancialStore((s) => s.statementLoading)
62
+ const paymentMethods = useFinancialStore((s) => s.paymentMethods)
63
+ const paymentMethodTypes = useFinancialStore((s) => s.paymentMethodTypes)
17
64
  const fetchBankAccounts = useFinancialStore((s) => s.fetchBankAccounts)
65
+ const fetchPaymentMethods = useFinancialStore((s) => s.fetchPaymentMethods)
18
66
  const fetchStatement = useFinancialStore((s) => s.fetchStatement)
19
67
 
20
68
  const [accountId, setAccountId] = useState('')
21
69
  const [dateFrom, setDateFrom] = useState(() => {
22
- const d = new Date(); d.setMonth(d.getMonth() - 1)
70
+ const d = new Date(); d.setMonth(d.getMonth() - 3)
23
71
  return d.toISOString().slice(0, 10)
24
72
  })
25
73
  const [dateTo, setDateTo] = useState(() => new Date().toISOString().slice(0, 10))
74
+ const [showTransfer, setShowTransfer] = useState(false)
75
+
76
+ useEffect(() => { fetchBankAccounts(); fetchPaymentMethods() }, [])
77
+
78
+ // Resolve a human payment-method label for a movement (method → type → card brand).
79
+ const methodName = useMemo(() => {
80
+ const byId = new Map(paymentMethods.map((m) => [m.id, m.name]))
81
+ const typeById = new Map(paymentMethodTypes.map((tp) => [tp.id, tp.name]))
82
+ return (mov: StatementEntry['movement']): string => {
83
+ if (mov.paymentMethodId && byId.has(mov.paymentMethodId)) return byId.get(mov.paymentMethodId)!
84
+ if (mov.paymentMethodTypeId && typeById.has(mov.paymentMethodTypeId)) return typeById.get(mov.paymentMethodTypeId)!
85
+ if (mov.cardBrand) return mov.cardBrand
86
+ return '—'
87
+ }
88
+ }, [paymentMethods, paymentMethodTypes])
26
89
 
27
- useEffect(() => { fetchBankAccounts() }, [])
90
+ // Open the linked invoice (receivable/payable detail) for a statement row.
91
+ function openInvoice(e: StatementEntry) {
92
+ if (!onNavigate || !e.movement.invoiceId) return
93
+ const base = e.movement.direction === 'credit' ? 'receivables' : 'payables'
94
+ onNavigate(`${base}-detail:${e.movement.invoiceId}`)
95
+ }
28
96
 
29
97
  useEffect(() => {
30
98
  if (accountId) {
31
- fetchStatement({ bankAccountId: accountId, dateRange: { from: dateFrom, to: dateTo } })
99
+ fetchStatement({
100
+ bankAccountId: accountId === ALL ? undefined : accountId,
101
+ dateRange: { from: dateFrom, to: dateTo },
102
+ })
32
103
  }
33
104
  }, [accountId, dateFrom, dateTo])
34
105
 
106
+ // Default to the consolidated "All accounts" view so paid invoices show up even
107
+ // when payments weren't tied to a specific bank account.
35
108
  useEffect(() => {
36
- if (!accountId && bankAccounts.length > 0) setAccountId(bankAccounts[0].id)
37
- }, [bankAccounts])
109
+ if (!accountId) setAccountId(ALL)
110
+ }, [])
111
+
112
+ const fmt = (n: number) => formatCurrency(n, currency)
38
113
 
39
114
  const columns: ColumnDef<StatementEntry, any>[] = useMemo(() => [
40
115
  {
@@ -45,40 +120,143 @@ export function StatementsView() {
45
120
  },
46
121
  {
47
122
  id: 'description', header: t('financial.statements.columnDescription'),
48
- cell: ({ row }) => (
49
- <div>
50
- <p className="text-xs font-medium">{row.original.invoice?.contactName || row.original.movement.notes || 'Transaction'}</p>
51
- <p className="text-[10px] text-muted-foreground capitalize">{row.original.movement.movementKind}</p>
52
- </div>
53
- ),
123
+ cell: ({ row }) => {
124
+ const e = row.original
125
+ const isTransfer = e.entryKind !== 'movement'
126
+ const memo = e.movement.notes
127
+ const title = e.invoice?.contactName
128
+ || (isTransfer ? t('financial.statements.transfer') : (memo || 'Transaction'))
129
+ // Show the memo on its own line unless it's already serving as the title.
130
+ const memoLine = memo && memo !== title ? memo : undefined
131
+ const doc = e.invoice?.fiscalNumber
132
+ const inst = e.movement.installmentNumber
133
+ const canOpen = !!onNavigate && !!e.movement.invoiceId
134
+ return (
135
+ <div>
136
+ <div className="flex items-center gap-1.5">
137
+ {/* Person name → hovercard with contact details (plain text when no contact) */}
138
+ <PersonLink personId={e.invoice?.contactId} name={title} size="sm" />
139
+ {/* Document reference → opens the linked invoice */}
140
+ {doc && (canOpen ? (
141
+ <button
142
+ onClick={() => openInvoice(e)}
143
+ className="text-[10px] text-muted-foreground font-mono hover:text-primary hover:underline"
144
+ title={t('financial.statements.viewInvoice')}
145
+ >
146
+ {doc}
147
+ </button>
148
+ ) : (
149
+ <span className="text-[10px] text-muted-foreground font-mono">{doc}</span>
150
+ ))}
151
+ {isTransfer && (
152
+ <span className="text-[9px] uppercase tracking-wide px-1 py-px rounded bg-warning/15 text-warning font-semibold">
153
+ {t('financial.statements.transfer')}
154
+ </span>
155
+ )}
156
+ {/* Soft reconciliation marker — optional, driven by what the plugin exposes:
157
+ reconciledAt = matched/accepted (conciliado); externalSource = imported from a
158
+ bank sync but not yet reconciled. Hover shows what it means. */}
159
+ {e.movement.reconciledAt ? (
160
+ <ReconcileMarker kind="reconciled" label={t('financial.statements.reconciled')} />
161
+ ) : e.movement.externalSource ? (
162
+ <ReconcileMarker kind="imported" label={t('financial.statements.imported')} />
163
+ ) : null}
164
+ {/* The transaction this bank line was conciliated with — link/id. */}
165
+ {e.reconciledWith && (() => {
166
+ const rw = e.reconciledWith
167
+ const refLabel = rw.fiscalNumber || rw.contactName || rw.description || rw.movementId.slice(0, 8)
168
+ const base = e.movement.direction === 'credit' ? 'receivables' : 'payables'
169
+ const onOpen = onNavigate && rw.invoiceId ? () => onNavigate(`${base}-detail:${rw.invoiceId}`) : undefined
170
+ return (
171
+ <ReconciledWithRef
172
+ refLabel={refLabel}
173
+ tooltip={t('financial.statements.reconciledWith', { ref: refLabel })}
174
+ onOpen={onOpen}
175
+ />
176
+ )
177
+ })()}
178
+ </div>
179
+ {memoLine && <p className="text-[11px] text-foreground/80">{memoLine}</p>}
180
+ <p className="text-[10px] text-muted-foreground">
181
+ <span className="capitalize">
182
+ {isTransfer && e.counterAccountName
183
+ ? (e.entryKind === 'transfer-in' ? `← ${e.counterAccountName}` : `→ ${e.counterAccountName}`)
184
+ : e.movement.movementKind}
185
+ </span>
186
+ {inst ? ` · ${t('financial.statements.installmentShort', { n: inst })}` : ''}
187
+ </p>
188
+ </div>
189
+ )
190
+ },
191
+ },
192
+ {
193
+ id: 'method', header: t('financial.statements.columnMethod'),
194
+ cell: ({ row }) => {
195
+ const mov = row.original.movement
196
+ const name = methodName(mov)
197
+ return (
198
+ <div>
199
+ <p className="text-xs">{name}</p>
200
+ {mov.cardInstallments && mov.cardInstallments > 1 && (
201
+ <p className="text-[10px] text-muted-foreground">{mov.cardInstallments}x</p>
202
+ )}
203
+ </div>
204
+ )
205
+ },
54
206
  },
55
207
  {
56
208
  id: 'debit', header: t('financial.statements.columnDebit'),
57
209
  cell: ({ row }) => (
58
210
  <span className="text-right block text-destructive text-xs">
59
- {row.original.movement.direction === 'debit' ? formatCurrency(row.original.movement.paidAmount, currency) : ''}
211
+ {row.original.movement.direction === 'debit' ? fmt(row.original.net) : ''}
60
212
  </span>
61
213
  ),
62
214
  },
63
215
  {
64
216
  id: 'credit', header: t('financial.statements.columnCredit'),
217
+ cell: ({ row }) => {
218
+ const e = row.original
219
+ if (e.movement.direction !== 'credit') return <span className="block text-right" />
220
+ return (
221
+ <div className="text-right">
222
+ <span className="block text-success text-xs">{fmt(e.net)}</span>
223
+ {e.fee > 0 && (
224
+ <span className="block text-[10px] text-muted-foreground">{fmt(e.gross)} − {fmt(e.fee)}</span>
225
+ )}
226
+ </div>
227
+ )
228
+ },
229
+ },
230
+ {
231
+ id: 'fee', header: t('financial.statements.columnFee'),
65
232
  cell: ({ row }) => (
66
- <span className="text-right block text-success text-xs">
67
- {row.original.movement.direction === 'credit' ? formatCurrency(row.original.movement.paidAmount, currency) : ''}
233
+ <span className="text-right block text-warning text-xs">
234
+ {row.original.fee > 0 ? fmt(row.original.fee) : ''}
68
235
  </span>
69
236
  ),
70
237
  },
71
238
  {
72
239
  id: 'balance', header: t('financial.statements.columnBalance'),
73
240
  cell: ({ row }) => (
74
- <span className="text-right block font-medium text-xs">{formatCurrency(row.original.runningBalance, currency)}</span>
241
+ <span className="text-right block font-medium text-xs">{fmt(row.original.runningBalance)}</span>
75
242
  ),
76
243
  },
77
- ], [currency, t])
244
+ ], [currency, t, methodName, onNavigate])
245
+
246
+ const entries = statement?.entries ?? []
78
247
 
79
248
  return (
80
249
  <div className="space-y-4">
81
- <SubpageHeader title={t('financial.statements.title')} subtitle={t('financial.statements.subtitle')} />
250
+ <SubpageHeader
251
+ title={t('financial.statements.title')}
252
+ subtitle={t('financial.statements.subtitle')}
253
+ actions={
254
+ <Button variant="outline" size="sm" onClick={() => setShowTransfer(true)} disabled={bankAccounts.length < 2}>
255
+ <ArrowRightLeft className="h-3.5 w-3.5" />
256
+ {t('financial.statements.newTransfer')}
257
+ </Button>
258
+ }
259
+ />
82
260
 
83
261
  {/* Filters */}
84
262
  <div className="flex flex-col sm:flex-row gap-3 sm:items-end">
@@ -87,9 +265,9 @@ export function StatementsView() {
87
265
  <select
88
266
  value={accountId}
89
267
  onChange={(e) => setAccountId(e.target.value)}
90
- className="w-full mt-0.5 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-3 py-2 text-sm"
268
+ className="w-full mt-0.5 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-3 py-2 text-sm"
91
269
  >
92
- <option value="">{t('financial.statements.selectAccount')}</option>
270
+ <option value={ALL}>{t('financial.summary.allAccounts')}</option>
93
271
  {bankAccounts.map((a) => (
94
272
  <option key={a.id} value={a.id}>{a.name}</option>
95
273
  ))}
@@ -105,6 +283,16 @@ export function StatementsView() {
105
283
  </div>
106
284
  </div>
107
285
 
286
+ {/* Summary header — standard bank-extract layout: opening → credits → debits → closing */}
287
+ {accountId && statement && (
288
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
289
+ <SummaryStat label={t('financial.statements.opening')} value={fmt(statement.openingBalance)} />
290
+ <SummaryStat label={t('financial.statements.totalCredits')} value={fmt(statement.totalCredits)} positive />
291
+ <SummaryStat label={t('financial.statements.totalDebits')} value={fmt(statement.totalDebits)} negative />
292
+ <SummaryStat label={t('financial.statements.closing')} value={fmt(statement.closingBalance)} accent />
293
+ </div>
294
+ )}
295
+
108
296
  {/* Statement table */}
109
297
  {!accountId ? (
110
298
  <div className="flex flex-col items-center justify-center py-12 text-center rounded-lg border-2 border-dashed border-muted">
@@ -112,12 +300,36 @@ export function StatementsView() {
112
300
  <p className="text-sm text-muted-foreground">{t('financial.statements.selectToView')}</p>
113
301
  </div>
114
302
  ) : statementLoading ? (
115
- <DataTable columns={columns} data={[]} loading skeletonRows={5} />
116
- ) : statementEntries.length === 0 ? (
117
- <DataTable columns={columns} data={[]} emptyMessage={t('financial.statements.noTransactions')} />
303
+ <DataTable columns={columns} data={[]} loading skeletonRows={8} compact />
304
+ ) : entries.length === 0 ? (
305
+ <DataTable columns={columns} data={[]} emptyMessage={t('financial.statements.noTransactions')} compact />
118
306
  ) : (
119
- <DataTable columns={columns} data={statementEntries} />
307
+ <DataTable columns={columns} data={entries} compact />
308
+ )}
309
+
310
+ {showTransfer && (
311
+ <TransferModal
312
+ defaultFromId={accountId && accountId !== ALL ? accountId : undefined}
313
+ onClose={() => setShowTransfer(false)}
314
+ onDone={() => setShowTransfer(false)}
315
+ />
120
316
  )}
121
317
  </div>
122
318
  )
123
319
  }
320
+
321
+ function SummaryStat({ label, value, accent, positive, negative }: {
322
+ label: string
323
+ value: string
324
+ accent?: boolean
325
+ positive?: boolean
326
+ negative?: boolean
327
+ }) {
328
+ const valueClass = positive ? 'text-success' : negative ? 'text-destructive' : accent ? 'text-foreground' : 'text-foreground'
329
+ return (
330
+ <div className={`rounded-lg border px-3 py-2 ${accent ? 'bg-muted/40' : 'bg-card'}`}>
331
+ <p className="text-[10px] font-medium text-muted-foreground uppercase truncate">{label}</p>
332
+ <p className={`text-sm font-semibold mt-0.5 ${valueClass}`}>{value}</p>
333
+ </div>
334
+ )
335
+ }
@@ -0,0 +1,24 @@
1
+ type TFunc = (key: string, params?: Record<string, string | number>) => string
2
+
3
+ // Maps a previous in-module view to the i18n key for its human label, used to
4
+ // render a context-aware back link ("Back to {where you came from}") on detail
5
+ // pages. Views not listed here (e.g. the edit/detail of the record we're
6
+ // returning from, or unknown views) are not meaningful referrers — callers fall
7
+ // back to the default parent label.
8
+ const REFERRER_LABEL_KEYS: Record<string, string> = {
9
+ summary: 'financial.nav.summary',
10
+ statements: 'financial.nav.statements',
11
+ 'cash-registers': 'financial.nav.cashRegisters',
12
+ reconciliation: 'financial.nav.reconciliation',
13
+ commissions: 'financial.nav.commissions',
14
+ cards: 'financial.nav.cards',
15
+ 'receivables-list': 'financial.nav.receivables',
16
+ 'payables-list': 'financial.nav.payables',
17
+ }
18
+
19
+ /** Human label for the page the user came from, or null when it isn't a meaningful referrer. */
20
+ export function referrerLabel(view: string | null | undefined, t: TFunc): string | null {
21
+ if (!view) return null
22
+ const key = REFERRER_LABEL_KEYS[view]
23
+ return key ? t(key) : null
24
+ }