@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.
- package/LICENSE +21 -0
- package/dist/FinancialContext.d.ts +2 -0
- package/dist/FinancialContext.d.ts.map +1 -1
- package/dist/FinancialPage.d.ts.map +1 -1
- package/dist/components/PaymentModal.d.ts.map +1 -1
- package/dist/components/PersonStatementWidget.d.ts +18 -0
- package/dist/components/PersonStatementWidget.d.ts.map +1 -0
- package/dist/components/TransferModal.d.ts +7 -0
- package/dist/components/TransferModal.d.ts.map +1 -0
- package/dist/contract.test.d.ts +2 -0
- package/dist/contract.test.d.ts.map +1 -0
- package/dist/data/capability.test.d.ts +2 -0
- package/dist/data/capability.test.d.ts.map +1 -0
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/data/types.d.ts +13 -2
- package/dist/data/types.d.ts.map +1 -1
- package/dist/index.cjs +1529 -263
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1437 -171
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/registries.d.ts.map +1 -1
- package/dist/store.d.ts +4 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/types.d.ts +111 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/views/InvoiceDetailView.d.ts +3 -1
- package/dist/views/InvoiceDetailView.d.ts.map +1 -1
- package/dist/views/InvoiceFormView.d.ts.map +1 -1
- package/dist/views/PayablesView.d.ts +5 -1
- package/dist/views/PayablesView.d.ts.map +1 -1
- package/dist/views/ReceivablesView.d.ts +5 -1
- package/dist/views/ReceivablesView.d.ts.map +1 -1
- package/dist/views/ReconciliationView.d.ts +3 -0
- package/dist/views/ReconciliationView.d.ts.map +1 -0
- package/dist/views/StatementsView.d.ts +3 -1
- package/dist/views/StatementsView.d.ts.map +1 -1
- package/dist/views/navReferrer.d.ts +5 -0
- package/dist/views/navReferrer.d.ts.map +1 -0
- package/package.json +8 -5
- package/src/FinancialContext.tsx +2 -0
- package/src/FinancialPage.tsx +32 -17
- package/src/components/PaymentModal.tsx +58 -11
- package/src/components/PersonStatementWidget.tsx +133 -0
- package/src/components/TransferModal.tsx +150 -0
- package/src/contract.test.ts +58 -0
- package/src/data/capability.test.ts +90 -0
- package/src/data/mock.ts +265 -52
- package/src/data/supabase.ts +359 -30
- package/src/data/types.ts +22 -3
- package/src/index.ts +28 -2
- package/src/locales/en.ts +76 -0
- package/src/locales/pt-BR.ts +76 -0
- package/src/migrations/006_extract_fee_amount.sql +20 -0
- package/src/migrations/006_rls_policies.sql +37 -0
- package/src/migrations/007_reconciliation.sql +32 -0
- package/src/migrations/008_split_payment_movements.sql +29 -0
- package/src/registries.ts +3 -1
- package/src/store.ts +27 -7
- package/src/types.ts +126 -1
- package/src/views/InvoiceDetailView.tsx +70 -51
- package/src/views/InvoiceFormView.tsx +55 -20
- package/src/views/PayablesView.tsx +13 -2
- package/src/views/ReceivablesView.tsx +13 -2
- package/src/views/ReconciliationView.tsx +211 -0
- package/src/views/StatementsView.tsx +241 -29
- package/src/views/navReferrer.ts +24 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { Wallet } from 'lucide-react'
|
|
3
|
+
import type { ColumnDef } from '@tanstack/react-table'
|
|
4
|
+
import { DataTable } from '@fayz-ai/ui'
|
|
5
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
6
|
+
import { formatCurrency, type ResolvedFinancialConfig } from '../FinancialContext'
|
|
7
|
+
import type { FinancialDataProvider } from '../data/types'
|
|
8
|
+
import type { Invoice } from '../types'
|
|
9
|
+
|
|
10
|
+
const STATUS_STYLE: Record<string, string> = {
|
|
11
|
+
paid: 'bg-success/15 text-success',
|
|
12
|
+
partial: 'bg-warning/15 text-warning',
|
|
13
|
+
overdue: 'bg-destructive/15 text-destructive',
|
|
14
|
+
open: 'bg-muted text-muted-foreground',
|
|
15
|
+
cancelled: 'bg-muted text-muted-foreground line-through',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Per-person financial statement (account_central-style extract): every invoice where
|
|
20
|
+
* this person is the contact — a client paying receivables, or a professional receiving
|
|
21
|
+
* commissions/payables. Rendered as a tab on the person detail page via the
|
|
22
|
+
* `person.detail.financial` widget zone.
|
|
23
|
+
*/
|
|
24
|
+
export function PersonStatementWidget({ person, provider, config }: {
|
|
25
|
+
person: { id?: string; name?: string }
|
|
26
|
+
provider: FinancialDataProvider
|
|
27
|
+
config: ResolvedFinancialConfig
|
|
28
|
+
}) {
|
|
29
|
+
const t = useTranslation()
|
|
30
|
+
const currency = config.currency
|
|
31
|
+
const [invoices, setInvoices] = useState<Invoice[]>([])
|
|
32
|
+
const [loading, setLoading] = useState(true)
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
let alive = true
|
|
36
|
+
if (!person?.id) { setLoading(false); return }
|
|
37
|
+
setLoading(true)
|
|
38
|
+
provider.getInvoices({ contactId: person.id, pageSize: 200 })
|
|
39
|
+
.then((r) => { if (alive) { setInvoices(r.data); setLoading(false) } })
|
|
40
|
+
.catch(() => { if (alive) setLoading(false) })
|
|
41
|
+
return () => { alive = false }
|
|
42
|
+
}, [person?.id, provider])
|
|
43
|
+
|
|
44
|
+
const fmt = (n: number) => formatCurrency(n, currency)
|
|
45
|
+
|
|
46
|
+
const totals = useMemo(() => {
|
|
47
|
+
const active = invoices.filter((i) => i.status !== 'cancelled')
|
|
48
|
+
const billed = active.reduce((s, i) => s + (i.totalAmount ?? 0), 0)
|
|
49
|
+
const paid = active.reduce((s, i) => s + (i.paidAmount ?? 0), 0)
|
|
50
|
+
return { billed, paid, balance: billed - paid }
|
|
51
|
+
}, [invoices])
|
|
52
|
+
|
|
53
|
+
const openInvoice = (inv: Invoice) => {
|
|
54
|
+
const base = inv.direction === 'credit' ? 'receivables' : 'payables'
|
|
55
|
+
window.location.hash = `/financial/${base}/detail/${inv.id}`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const columns: ColumnDef<Invoice, any>[] = useMemo(() => [
|
|
59
|
+
{
|
|
60
|
+
id: 'date', header: t('financial.invoice.columnDate'),
|
|
61
|
+
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.invoiceDate}</span>,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'description', header: t('financial.invoice.columnDescription'),
|
|
65
|
+
cell: ({ row }) => {
|
|
66
|
+
const inv = row.original
|
|
67
|
+
return (
|
|
68
|
+
<button onClick={() => openInvoice(inv)} className="text-left">
|
|
69
|
+
<span className="text-xs font-medium text-primary hover:underline">
|
|
70
|
+
{inv.itemsSummary || inv.contactName || t('financial.invoice.invoice')}
|
|
71
|
+
</span>
|
|
72
|
+
{inv.fiscalNumber && <span className="ml-1.5 text-[10px] text-muted-foreground font-mono">{inv.fiscalNumber}</span>}
|
|
73
|
+
</button>
|
|
74
|
+
)
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'amount', header: t('financial.invoice.columnAmount'),
|
|
79
|
+
cell: ({ row }) => (
|
|
80
|
+
<span className={`text-right block text-xs ${row.original.direction === 'credit' ? 'text-success' : 'text-destructive'}`}>
|
|
81
|
+
{fmt(row.original.totalAmount)}
|
|
82
|
+
</span>
|
|
83
|
+
),
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'paid', header: t('financial.invoice.columnPaid'),
|
|
87
|
+
cell: ({ row }) => <span className="text-right block text-xs text-muted-foreground">{fmt(row.original.paidAmount)}</span>,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 'balance', header: t('financial.statements.columnBalance'),
|
|
91
|
+
cell: ({ row }) => <span className="text-right block text-xs font-medium">{fmt((row.original.totalAmount ?? 0) - (row.original.paidAmount ?? 0))}</span>,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: 'status', header: t('financial.invoice.columnStatus'),
|
|
95
|
+
cell: ({ row }) => (
|
|
96
|
+
<span className={`inline-flex rounded-full px-1.5 py-0.5 text-[9px] font-medium capitalize ${STATUS_STYLE[row.original.status] ?? STATUS_STYLE.open}`}>
|
|
97
|
+
{row.original.status}
|
|
98
|
+
</span>
|
|
99
|
+
),
|
|
100
|
+
},
|
|
101
|
+
], [currency, t])
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className="space-y-3">
|
|
105
|
+
{/* Totals */}
|
|
106
|
+
<div className="grid grid-cols-3 gap-2">
|
|
107
|
+
<Stat label={t('financial.invoice.summaryTotal')} value={fmt(totals.billed)} />
|
|
108
|
+
<Stat label={t('financial.invoice.summaryPaid')} value={fmt(totals.paid)} positive />
|
|
109
|
+
<Stat label={t('financial.statements.columnBalance')} value={fmt(totals.balance)} accent />
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{loading ? (
|
|
113
|
+
<DataTable columns={columns} data={[]} loading skeletonRows={5} compact />
|
|
114
|
+
) : invoices.length === 0 ? (
|
|
115
|
+
<div className="flex flex-col items-center justify-center py-10 text-center rounded-lg border-2 border-dashed border-muted">
|
|
116
|
+
<Wallet className="h-7 w-7 text-muted-foreground mb-2" />
|
|
117
|
+
<p className="text-sm text-muted-foreground">{t('financial.person.empty')}</p>
|
|
118
|
+
</div>
|
|
119
|
+
) : (
|
|
120
|
+
<DataTable columns={columns} data={invoices} compact />
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function Stat({ label, value, accent, positive }: { label: string; value: string; accent?: boolean; positive?: boolean }) {
|
|
127
|
+
return (
|
|
128
|
+
<div className={`rounded-lg border px-3 py-2 ${accent ? 'bg-muted/40' : 'bg-card'}`}>
|
|
129
|
+
<p className="text-[10px] font-medium text-muted-foreground uppercase truncate">{label}</p>
|
|
130
|
+
<p className={`text-sm font-semibold mt-0.5 ${positive ? 'text-success' : 'text-foreground'}`}>{value}</p>
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { X, ArrowRightLeft } from 'lucide-react'
|
|
4
|
+
import { useFinancialConfig, useFinancialStore } from '../FinancialContext'
|
|
5
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
6
|
+
import { CurrencyInput, DatePicker, Button } from '@fayz-ai/ui'
|
|
7
|
+
|
|
8
|
+
export function TransferModal({ onClose, onDone, defaultFromId }: {
|
|
9
|
+
onClose: () => void
|
|
10
|
+
onDone: () => void
|
|
11
|
+
defaultFromId?: string
|
|
12
|
+
}) {
|
|
13
|
+
const t = useTranslation()
|
|
14
|
+
const { currency } = useFinancialConfig()
|
|
15
|
+
const bankAccounts = useFinancialStore((s) => s.bankAccounts)
|
|
16
|
+
const createTransfer = useFinancialStore((s) => s.createTransfer)
|
|
17
|
+
const fetchBankAccounts = useFinancialStore((s) => s.fetchBankAccounts)
|
|
18
|
+
|
|
19
|
+
const [fromAccountId, setFromAccountId] = useState(defaultFromId ?? '')
|
|
20
|
+
const [toAccountId, setToAccountId] = useState('')
|
|
21
|
+
const [amount, setAmount] = useState(0)
|
|
22
|
+
const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
|
|
23
|
+
const [notes, setNotes] = useState('')
|
|
24
|
+
const [saving, setSaving] = useState(false)
|
|
25
|
+
const [visible, setVisible] = useState(false)
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
fetchBankAccounts()
|
|
29
|
+
requestAnimationFrame(() => setVisible(true))
|
|
30
|
+
}, [])
|
|
31
|
+
|
|
32
|
+
function handleClose() {
|
|
33
|
+
setVisible(false)
|
|
34
|
+
setTimeout(onClose, 200)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const sameAccount = !!fromAccountId && fromAccountId === toAccountId
|
|
38
|
+
const canSubmit = !!fromAccountId && !!toAccountId && !sameAccount && amount > 0 && !saving
|
|
39
|
+
|
|
40
|
+
// Enter anywhere in the modal submits (unless typing in a textarea / Shift+Enter).
|
|
41
|
+
function handleKeyDown(e: React.KeyboardEvent) {
|
|
42
|
+
if (e.key !== 'Enter' || e.shiftKey) return
|
|
43
|
+
if ((e.target as HTMLElement).tagName === 'TEXTAREA') return
|
|
44
|
+
e.preventDefault()
|
|
45
|
+
if (canSubmit) void handleSubmit()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function handleSubmit() {
|
|
49
|
+
if (!canSubmit) return
|
|
50
|
+
setSaving(true)
|
|
51
|
+
try {
|
|
52
|
+
await createTransfer({ fromAccountId, toAccountId, amount, date, notes: notes || undefined })
|
|
53
|
+
setVisible(false)
|
|
54
|
+
setTimeout(onDone, 200)
|
|
55
|
+
} catch {
|
|
56
|
+
// toast handled by store
|
|
57
|
+
} finally {
|
|
58
|
+
setSaving(false)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const selectClass = 'w-full mt-1 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-3 py-2 text-sm'
|
|
63
|
+
|
|
64
|
+
return createPortal(
|
|
65
|
+
<div
|
|
66
|
+
className="fixed inset-0 z-50 flex items-center justify-center transition-colors duration-200"
|
|
67
|
+
style={{ backgroundColor: visible ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0)' }}
|
|
68
|
+
onClick={handleClose}
|
|
69
|
+
>
|
|
70
|
+
<div
|
|
71
|
+
className="w-full max-w-md rounded-modal border bg-card shadow-lg mx-4 transition-all duration-200 max-h-[90vh] overflow-y-auto"
|
|
72
|
+
style={{ opacity: visible ? 1 : 0, transform: visible ? 'scale(1) translateY(0)' : 'scale(0.95) translateY(8px)' }}
|
|
73
|
+
onClick={(e) => e.stopPropagation()}
|
|
74
|
+
onKeyDown={handleKeyDown}
|
|
75
|
+
>
|
|
76
|
+
{/* Header */}
|
|
77
|
+
<div className="flex items-center justify-between px-5 py-4 border-b">
|
|
78
|
+
<div className="flex items-center gap-2">
|
|
79
|
+
<ArrowRightLeft className="h-4 w-4 text-warning" />
|
|
80
|
+
<h3 className="text-base font-semibold">{t('financial.transfer.title')}</h3>
|
|
81
|
+
</div>
|
|
82
|
+
<button onClick={handleClose} className="p-1 text-muted-foreground hover:text-foreground transition-colors">
|
|
83
|
+
<X className="h-4 w-4" />
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div className="px-5 py-4 space-y-4">
|
|
88
|
+
<div className="grid grid-cols-2 gap-3">
|
|
89
|
+
<div>
|
|
90
|
+
<label className="text-xs font-medium text-muted-foreground">{t('financial.transfer.from')}</label>
|
|
91
|
+
<select value={fromAccountId} onChange={(e) => setFromAccountId(e.target.value)} className={selectClass}>
|
|
92
|
+
<option value="">{t('financial.transfer.selectAccount')}</option>
|
|
93
|
+
{bankAccounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
|
94
|
+
</select>
|
|
95
|
+
</div>
|
|
96
|
+
<div>
|
|
97
|
+
<label className="text-xs font-medium text-muted-foreground">{t('financial.transfer.to')}</label>
|
|
98
|
+
<select value={toAccountId} onChange={(e) => setToAccountId(e.target.value)} className={selectClass}>
|
|
99
|
+
<option value="">{t('financial.transfer.selectAccount')}</option>
|
|
100
|
+
{bankAccounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
|
101
|
+
</select>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
{sameAccount && (
|
|
106
|
+
<p className="text-xs text-destructive">{t('financial.transfer.sameAccount')}</p>
|
|
107
|
+
)}
|
|
108
|
+
|
|
109
|
+
<div className="grid grid-cols-2 gap-3">
|
|
110
|
+
<CurrencyInput
|
|
111
|
+
label={t('financial.transfer.amount')}
|
|
112
|
+
value={amount}
|
|
113
|
+
onChange={setAmount}
|
|
114
|
+
symbol={currency.symbol}
|
|
115
|
+
locale={currency.locale}
|
|
116
|
+
currencyCode={currency.code}
|
|
117
|
+
/>
|
|
118
|
+
<div>
|
|
119
|
+
<label className="text-xs font-medium text-muted-foreground">{t('financial.transfer.date')}</label>
|
|
120
|
+
<DatePicker value={date} onChange={setDate} className="mt-1" />
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div>
|
|
125
|
+
<label className="text-xs font-medium text-muted-foreground">{t('financial.transfer.notes')}</label>
|
|
126
|
+
<input
|
|
127
|
+
type="text"
|
|
128
|
+
value={notes}
|
|
129
|
+
onChange={(e) => setNotes(e.target.value)}
|
|
130
|
+
placeholder={t('financial.transfer.optionalNotes')}
|
|
131
|
+
className="w-full mt-1 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
{/* Actions */}
|
|
137
|
+
<div className="flex justify-end gap-2 px-5 py-4 border-t">
|
|
138
|
+
<Button variant="outline" size="sm" onClick={handleClose}>
|
|
139
|
+
{t('financial.transfer.cancel')}
|
|
140
|
+
</Button>
|
|
141
|
+
<Button variant="default" size="sm" onClick={handleSubmit} disabled={!canSubmit}>
|
|
142
|
+
<ArrowRightLeft className="h-3.5 w-3.5" />
|
|
143
|
+
{saving ? t('financial.transfer.processing') : t('financial.transfer.submit')}
|
|
144
|
+
</Button>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</div>,
|
|
148
|
+
document.body,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Integrity test for the shared plugin-contract assertions (@fayz-ai/core/testing).
|
|
2
|
+
// Exercises the gate's rules with inline manifests (no UI import) so the tool
|
|
3
|
+
// itself is proven: a conformant manifest passes; each malformed shape throws.
|
|
4
|
+
// Real plugins call assertPluginManifestContract(theirManifest) in an env where
|
|
5
|
+
// their UI bundle loads (app-level / jsdom). See PLUGIN_PATTERNS.md → integrity.
|
|
6
|
+
import { describe, it, expect } from 'vitest'
|
|
7
|
+
import { assertPluginManifestContract, PluginContractError } from '@fayz-ai/core/testing'
|
|
8
|
+
import type { PluginManifest } from '@fayz-ai/core'
|
|
9
|
+
|
|
10
|
+
const Dummy = () => null
|
|
11
|
+
|
|
12
|
+
function manifest(overrides: Partial<PluginManifest> = {}): PluginManifest {
|
|
13
|
+
return {
|
|
14
|
+
id: 'demo', name: 'Demo', icon: 'Box', version: '1.0.0',
|
|
15
|
+
navigation: [{ section: 'main', label: 'Demo', route: '/demo', icon: 'Box' } as any],
|
|
16
|
+
routes: [{ path: '/demo', component: Dummy } as any],
|
|
17
|
+
...overrides,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('assertPluginManifestContract', () => {
|
|
22
|
+
it('passes a conformant page plugin', () => {
|
|
23
|
+
expect(() => assertPluginManifestContract(manifest())).not.toThrow()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('passes a settings-only plugin (nav/routes empty, one settings tab)', () => {
|
|
27
|
+
expect(() => assertPluginManifestContract(manifest({
|
|
28
|
+
navigation: [], routes: [],
|
|
29
|
+
settings: [{ id: 'demo', label: 'Demo', icon: 'Box', component: Dummy } as any],
|
|
30
|
+
}))).not.toThrow()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('rejects a missing id', () => {
|
|
34
|
+
expect(() => assertPluginManifestContract(manifest({ id: '' }))).toThrow(PluginContractError)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('rejects a nav entry pointing at an undeclared route', () => {
|
|
38
|
+
expect(() => assertPluginManifestContract(manifest({
|
|
39
|
+
navigation: [{ section: 'main', label: 'X', route: '/nowhere', icon: 'Box' } as any],
|
|
40
|
+
}))).toThrow(/no matching route/)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('rejects duplicate route paths', () => {
|
|
44
|
+
expect(() => assertPluginManifestContract(manifest({
|
|
45
|
+
routes: [{ path: '/demo', component: Dummy } as any, { path: '/demo', component: Dummy } as any],
|
|
46
|
+
}))).toThrow(/duplicate route/)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('rejects a settings tab without a component', () => {
|
|
50
|
+
expect(() => assertPluginManifestContract(manifest({
|
|
51
|
+
settings: [{ id: 'demo', label: 'Demo' } as any],
|
|
52
|
+
}))).toThrow(/component/)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('rejects a plugin that contributes nothing mountable', () => {
|
|
56
|
+
expect(() => assertPluginManifestContract(manifest({ navigation: [], routes: [], settings: [] }))).toThrow(/nothing/)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Capability + contract test for plugin-financial (Plugin Capability Contract).
|
|
2
|
+
// Proves the data slice end-to-end against the mock provider — including the
|
|
3
|
+
// ⚖️ reconciliation (conciliação) feature: import bank lines → list unreconciled
|
|
4
|
+
// → suggest a match → reconcile → assert it leaves the pending set. Rows are
|
|
5
|
+
// created manually (mock provider), which is exactly how a capability is proven
|
|
6
|
+
// before/without a live backend. Mirrors plugins/plugin-tasks capability.test.ts.
|
|
7
|
+
import { describe, it, expect } from 'vitest'
|
|
8
|
+
import { createMockFinancialProvider } from './mock'
|
|
9
|
+
|
|
10
|
+
describe('plugin-financial · invoice/movement slice (mock provider)', () => {
|
|
11
|
+
it('persists an invoice and its installment movements', async () => {
|
|
12
|
+
const provider = createMockFinancialProvider()
|
|
13
|
+
const invoice = await provider.createInvoice({
|
|
14
|
+
direction: 'credit',
|
|
15
|
+
invoiceDate: '2026-06-01',
|
|
16
|
+
contactName: 'Acme',
|
|
17
|
+
items: [{ itemKind: 'other', description: 'Consulting', quantity: 1, unitPrice: 300 }],
|
|
18
|
+
installments: [
|
|
19
|
+
{ direction: 'credit', movementKind: 'bill', amount: 300, dueDate: '2026-06-10', installmentNumber: 1 },
|
|
20
|
+
],
|
|
21
|
+
})
|
|
22
|
+
expect(invoice.id).toBeTruthy()
|
|
23
|
+
expect(invoice.totalAmount).toBe(300)
|
|
24
|
+
|
|
25
|
+
const movs = await provider.getMovements({ direction: 'credit' })
|
|
26
|
+
expect(movs.data.some((m) => m.invoiceId === invoice.id && m.amount === 300)).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('plugin-financial · ⚖️ reconciliation capability (mock provider)', () => {
|
|
31
|
+
it('imports bank lines idempotently', async () => {
|
|
32
|
+
const provider = createMockFinancialProvider()
|
|
33
|
+
const lines = [
|
|
34
|
+
{ externalId: 'tx-1', externalSource: 'plugbank', direction: 'credit' as const, amount: 150, date: '2026-06-10', description: 'PIX recebido' },
|
|
35
|
+
{ externalId: 'tx-2', externalSource: 'plugbank', direction: 'debit' as const, amount: 80, date: '2026-06-11', description: 'Tarifa' },
|
|
36
|
+
]
|
|
37
|
+
const first = await provider.importBankTransactions!({ lines })
|
|
38
|
+
expect(first.imported).toBe(2)
|
|
39
|
+
expect(first.duplicates).toBe(0)
|
|
40
|
+
|
|
41
|
+
// Re-importing the same lines must not duplicate (idempotency by external id).
|
|
42
|
+
const second = await provider.importBankTransactions!({ lines })
|
|
43
|
+
expect(second.imported).toBe(0)
|
|
44
|
+
expect(second.duplicates).toBe(2)
|
|
45
|
+
|
|
46
|
+
const pending = await provider.getUnreconciled!()
|
|
47
|
+
expect(pending).toHaveLength(2)
|
|
48
|
+
expect(pending.every((m) => m.externalSource === 'plugbank' && !m.reconciledAt)).toBe(true)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('suggests and matches an internal movement, leaving the pending set', async () => {
|
|
52
|
+
const provider = createMockFinancialProvider()
|
|
53
|
+
// An internal receivable the bank line should reconcile against.
|
|
54
|
+
await provider.createInvoice({
|
|
55
|
+
direction: 'credit',
|
|
56
|
+
invoiceDate: '2026-06-08',
|
|
57
|
+
contactName: 'Acme',
|
|
58
|
+
items: [{ itemKind: 'other', description: 'Service', quantity: 1, unitPrice: 150 }],
|
|
59
|
+
installments: [{ direction: 'credit', movementKind: 'bill', amount: 150, dueDate: '2026-06-10', installmentNumber: 1 }],
|
|
60
|
+
})
|
|
61
|
+
// The imported bank line (same amount, same direction, near date).
|
|
62
|
+
await provider.importBankTransactions!({
|
|
63
|
+
lines: [{ externalId: 'tx-1', externalSource: 'plugbank', direction: 'credit', amount: 150, date: '2026-06-10', description: 'PIX Acme' }],
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const [bankLine] = await provider.getUnreconciled!()
|
|
67
|
+
expect(bankLine).toBeTruthy()
|
|
68
|
+
|
|
69
|
+
const candidates = await provider.suggestReconciliation!(bankLine.id)
|
|
70
|
+
expect(candidates.length).toBeGreaterThan(0)
|
|
71
|
+
expect(candidates[0].movement.amount).toBe(150)
|
|
72
|
+
expect(candidates[0].score).toBeGreaterThan(0.5)
|
|
73
|
+
|
|
74
|
+
await provider.reconcileMovement!({ bankMovementId: bankLine.id, matchedMovementId: candidates[0].movement.id })
|
|
75
|
+
|
|
76
|
+
// The line is gone from the pending set and both sides are marked reconciled.
|
|
77
|
+
const stillPending = await provider.getUnreconciled!()
|
|
78
|
+
expect(stillPending.some((m) => m.id === bankLine.id)).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('accepts a bank line standalone when there is no internal counterpart', async () => {
|
|
82
|
+
const provider = createMockFinancialProvider()
|
|
83
|
+
await provider.importBankTransactions!({
|
|
84
|
+
lines: [{ externalId: 'tx-9', externalSource: 'plugbank', direction: 'debit', amount: 42, date: '2026-06-12', description: 'Taxa avulsa' }],
|
|
85
|
+
})
|
|
86
|
+
const [line] = await provider.getUnreconciled!()
|
|
87
|
+
await provider.reconcileMovement!({ bankMovementId: line.id }) // no match → accept standalone
|
|
88
|
+
expect(await provider.getUnreconciled!()).toHaveLength(0)
|
|
89
|
+
})
|
|
90
|
+
})
|