@fayz-ai/plugin-financial 0.2.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/FinancialContext.d.ts +6 -0
- package/dist/FinancialContext.d.ts.map +1 -1
- package/dist/FinancialPage.d.ts.map +1 -1
- package/dist/components/FinancialGeneralSettings.d.ts.map +1 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/data/tables.d.ts +11 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/index.cjs +345 -81
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +347 -84
- 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/migrations/index.d.ts +18 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/registries.d.ts.map +1 -1
- package/dist/views/CardsView.d.ts.map +1 -1
- package/dist/views/InvoiceFormView.d.ts.map +1 -1
- package/dist/views/InvoiceListView.d.ts.map +1 -1
- package/dist/views/QuickTransactionForm.d.ts.map +1 -1
- package/package.json +10 -4
- package/src/FinancialContext.tsx +6 -0
- package/src/FinancialPage.tsx +56 -12
- package/src/README.md +5 -5
- package/src/components/FinancialGeneralSettings.tsx +21 -8
- package/src/contract.test.ts +1 -1
- package/src/data/supabase.ts +56 -48
- package/src/data/tables.ts +12 -0
- package/src/index.ts +128 -0
- package/src/locales/en.ts +10 -0
- package/src/locales/pt-BR.ts +10 -0
- package/src/migrations/000_plg_rename.sql +30 -0
- package/src/migrations/001_financial_base.sql +30 -30
- package/src/migrations/002_chart_of_accounts.sql +10 -10
- package/src/migrations/003_card_brands.sql +4 -4
- package/src/migrations/004_order_to_cash.sql +43 -29
- package/src/migrations/005_seed_defaults.sql +6 -6
- package/src/migrations/006_rls_policies.sql +5 -5
- package/src/migrations/{006_extract_fee_amount.sql → 006b_extract_fee_amount.sql} +4 -4
- package/src/migrations/007_reconciliation.sql +9 -9
- package/src/migrations/007b_movement_payment_method_type.sql +6 -0
- package/src/migrations/008_split_payment_movements.sql +5 -5
- package/src/migrations/009_agent_rpcs.sql +165 -0
- package/src/migrations/010_invoice_internal.sql +133 -0
- package/src/migrations/index.ts +862 -0
- package/src/registries.ts +8 -7
- package/src/views/CardsView.tsx +12 -9
- package/src/views/InvoiceFormView.tsx +10 -0
- package/src/views/InvoiceListView.tsx +98 -9
- package/src/views/QuickTransactionForm.tsx +11 -0
package/src/registries.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PluginRegistryDef } from '@fayz-ai/core'
|
|
2
2
|
import type { EntityDef } from '@fayz-ai/core'
|
|
3
|
+
import { T } from './data/tables'
|
|
3
4
|
|
|
4
5
|
// ---------------------------------------------------------------------------
|
|
5
6
|
// Payment Method Types (seeded — Cash, PIX, Credit Card, etc.)
|
|
@@ -16,7 +17,7 @@ const paymentMethodTypeEntity: EntityDef = {
|
|
|
16
17
|
{ key: 'transactionType', label: 'Transaction Type', type: 'select', options: ['cash', 'credit_card', 'debit_card', 'pix', 'bank_transfer', 'check', 'voucher'], showInTable: true, required: true },
|
|
17
18
|
{ key: 'isActive', label: 'Active', type: 'boolean', showInTable: true, defaultValue: true },
|
|
18
19
|
],
|
|
19
|
-
data: { table:
|
|
20
|
+
data: { table: T.paymentMethodTypes, tenantScoped: true },
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
// ---------------------------------------------------------------------------
|
|
@@ -33,14 +34,14 @@ const paymentMethodEntity: EntityDef = {
|
|
|
33
34
|
{ key: 'name', label: 'Name', type: 'text', required: true, showInTable: true },
|
|
34
35
|
// FK to the seeded payment_method_types catalog — load real ids at runtime
|
|
35
36
|
// (a static enum select would write the string into the uuid column).
|
|
36
|
-
{ key: 'paymentMethodTypeId', label: 'Type', type: 'relation', relation: { table:
|
|
37
|
+
{ key: 'paymentMethodTypeId', label: 'Type', type: 'relation', relation: { table: T.paymentMethodTypes, labelField: 'name' }, showInTable: true, required: true },
|
|
37
38
|
{ key: 'discountValue', label: 'Discount %', type: 'number', showInTable: true },
|
|
38
39
|
{ key: 'interestValue', label: 'Interest %', type: 'number', showInTable: true },
|
|
39
40
|
{ key: 'minInstallments', label: 'Min Installments', type: 'number', defaultValue: 1 },
|
|
40
41
|
{ key: 'maxInstallments', label: 'Max Installments', type: 'number', defaultValue: 1, showInTable: true },
|
|
41
42
|
{ key: 'isActive', label: 'Active', type: 'boolean', showInTable: true, defaultValue: true },
|
|
42
43
|
],
|
|
43
|
-
data: { table:
|
|
44
|
+
data: { table: T.paymentMethods, tenantScoped: true },
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
// ---------------------------------------------------------------------------
|
|
@@ -60,7 +61,7 @@ const chartOfAccountsEntity: EntityDef = {
|
|
|
60
61
|
{ key: 'parentId', label: 'Parent', type: 'text', showInTable: false },
|
|
61
62
|
{ key: 'isActive', label: 'Active', type: 'boolean', showInTable: true, defaultValue: true },
|
|
62
63
|
],
|
|
63
|
-
data: { table:
|
|
64
|
+
data: { table: T.chartOfAccounts, tenantScoped: true },
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
// ---------------------------------------------------------------------------
|
|
@@ -78,7 +79,7 @@ const costCenterEntity: EntityDef = {
|
|
|
78
79
|
{ key: 'name', label: 'Name', type: 'text', required: true, showInTable: true },
|
|
79
80
|
{ key: 'isActive', label: 'Active', type: 'boolean', showInTable: true, defaultValue: true },
|
|
80
81
|
],
|
|
81
|
-
data: { table:
|
|
82
|
+
data: { table: T.costCenters, tenantScoped: true },
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
// ---------------------------------------------------------------------------
|
|
@@ -108,7 +109,7 @@ const bankAccountEntity: EntityDef = {
|
|
|
108
109
|
{ key: 'dueDay', label: 'Due Day', type: 'number' },
|
|
109
110
|
{ key: 'isActive', label: 'Active', type: 'boolean', showInTable: true, defaultValue: true },
|
|
110
111
|
],
|
|
111
|
-
data: { table:
|
|
112
|
+
data: { table: T.bankAccounts, tenantScoped: true },
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
// ---------------------------------------------------------------------------
|
|
@@ -125,7 +126,7 @@ const cardBrandEntity: EntityDef = {
|
|
|
125
126
|
{ key: 'name', label: 'Name', type: 'text', required: true, showInTable: true },
|
|
126
127
|
{ key: 'isActive', label: 'Active', type: 'boolean', showInTable: true, defaultValue: true },
|
|
127
128
|
],
|
|
128
|
-
data: { table:
|
|
129
|
+
data: { table: T.cardBrands, tenantScoped: true },
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
// ---------------------------------------------------------------------------
|
package/src/views/CardsView.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'
|
|
|
2
2
|
import { CreditCard, Plus, CalendarClock } from 'lucide-react'
|
|
3
3
|
import { useFinancialConfig, useFinancialStore, formatCurrency } from '../FinancialContext'
|
|
4
4
|
import { SubpageHeader } from '@fayz-ai/ui'
|
|
5
|
+
import { PermissionGate } from '@fayz-ai/saas'
|
|
5
6
|
import { useTranslation } from '@fayz-ai/core'
|
|
6
7
|
import type { BankAccount } from '../types'
|
|
7
8
|
import { QuickTransactionForm } from './QuickTransactionForm'
|
|
@@ -182,15 +183,17 @@ export function CardsView() {
|
|
|
182
183
|
)}
|
|
183
184
|
</div>
|
|
184
185
|
|
|
185
|
-
{/* Add expense against this card */}
|
|
186
|
-
<
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
186
|
+
{/* Add expense against this card — gated on the role's create action */}
|
|
187
|
+
<PermissionGate feature="financial" action="create">
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
onClick={() => openAddExpense(card.id)}
|
|
191
|
+
className="flex w-full items-center justify-center gap-1.5 rounded-input border border-input bg-card px-3 py-2 text-xs font-medium text-foreground transition-colors hover:bg-muted/40"
|
|
192
|
+
>
|
|
193
|
+
<Plus className="h-3.5 w-3.5" />
|
|
194
|
+
{t('financial.cards.addExpense')}
|
|
195
|
+
</button>
|
|
196
|
+
</PermissionGate>
|
|
194
197
|
</div>
|
|
195
198
|
</div>
|
|
196
199
|
)
|
|
@@ -7,6 +7,7 @@ import { CurrencyInput } from '@fayz-ai/ui'
|
|
|
7
7
|
import { DatePicker } from '@fayz-ai/ui'
|
|
8
8
|
import { Button } from '@fayz-ai/ui'
|
|
9
9
|
import type { EntityLookupMap } from '@fayz-ai/saas'
|
|
10
|
+
import { useLimitGuard, invalidateLimit, usePermissionOptional } from '@fayz-ai/saas'
|
|
10
11
|
import { SubpageHeader, useSaveBar, toast } from '@fayz-ai/ui'
|
|
11
12
|
import { useTranslation } from '@fayz-ai/core'
|
|
12
13
|
import type { TransactionDirection, CreateInvoiceItemInput, CreateMovementInput } from '../types'
|
|
@@ -401,6 +402,8 @@ export function InvoiceFormView({ direction, editId, onSaved }: {
|
|
|
401
402
|
itemTypes[0]?.value ??
|
|
402
403
|
'other'
|
|
403
404
|
const createInvoice = useFinancialStore((s) => s.createInvoice)
|
|
405
|
+
const guardMovements = useLimitGuard('movements_month')
|
|
406
|
+
const can = usePermissionOptional()
|
|
404
407
|
const hasLocations = locations.length > 0
|
|
405
408
|
const isEdit = !!editId
|
|
406
409
|
|
|
@@ -565,7 +568,13 @@ export function InvoiceFormView({ direction, editId, onSaved }: {
|
|
|
565
568
|
}
|
|
566
569
|
|
|
567
570
|
async function handleSave() {
|
|
571
|
+
// Defense in depth: block creating a new invoice without the `create` action.
|
|
572
|
+
if (!isEdit && !can('financial', 'create')) return
|
|
568
573
|
if (items.length === 0) { toast.error(t('common.formIncomplete')); return }
|
|
574
|
+
// Plan quantity guard (client-side): an invoice writes one financial movement
|
|
575
|
+
// per installment (min 1). Guard that many against the monthly cap before the
|
|
576
|
+
// store call — the guard opens the global UpgradeModal when it would exceed.
|
|
577
|
+
if (!isEdit && (await guardMovements(Math.max(1, installments.length))) === 'blocked') return
|
|
569
578
|
setSaving(true)
|
|
570
579
|
try {
|
|
571
580
|
const movInputs: CreateMovementInput[] = installments.map((inst, i) => ({
|
|
@@ -609,6 +618,7 @@ export function InvoiceFormView({ direction, editId, onSaved }: {
|
|
|
609
618
|
items: items.map(({ _id, ...rest }) => rest),
|
|
610
619
|
installments: movInputs,
|
|
611
620
|
})
|
|
621
|
+
invalidateLimit('movements_month')
|
|
612
622
|
onSaved?.(invoice.id)
|
|
613
623
|
}
|
|
614
624
|
} finally {
|
|
@@ -4,7 +4,7 @@ import type { ColumnDef } from '@tanstack/react-table'
|
|
|
4
4
|
import { ListView } from '@fayz-ai/ui'
|
|
5
5
|
import { useFinancialConfig, useFinancialProvider, useFinancialStore, formatCurrency } from '../FinancialContext'
|
|
6
6
|
import { SubpageHeader } from '@fayz-ai/ui'
|
|
7
|
-
import { PersonLink } from '@fayz-ai/saas'
|
|
7
|
+
import { PersonLink, usePermissionOptional } from '@fayz-ai/saas'
|
|
8
8
|
import { useTranslation } from '@fayz-ai/core'
|
|
9
9
|
import type { TransactionDirection, InvoiceStatus, Invoice, ChartOfAccountsNode, CostCenter } from '../types'
|
|
10
10
|
|
|
@@ -16,6 +16,30 @@ const STATUS_OPTIONS: { value: InvoiceStatus; labelKey: string; color: string; i
|
|
|
16
16
|
{ value: 'cancelled', labelKey: 'financial.invoice.statusCancelled', color: 'bg-muted text-muted-foreground', icon: Ban },
|
|
17
17
|
]
|
|
18
18
|
|
|
19
|
+
const DATE_PRESETS = ['thisMonth', 'lastMonth', 'last30', 'thisYear'] as const
|
|
20
|
+
type DatePreset = (typeof DATE_PRESETS)[number]
|
|
21
|
+
|
|
22
|
+
const isoDate = (d: Date) =>
|
|
23
|
+
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
24
|
+
|
|
25
|
+
/** Local-time boundaries so "this month" means the user's month, not UTC's. */
|
|
26
|
+
function presetRange(preset: DatePreset): { from: string; to: string } {
|
|
27
|
+
const now = new Date()
|
|
28
|
+
switch (preset) {
|
|
29
|
+
case 'thisMonth':
|
|
30
|
+
return { from: isoDate(new Date(now.getFullYear(), now.getMonth(), 1)), to: isoDate(new Date(now.getFullYear(), now.getMonth() + 1, 0)) }
|
|
31
|
+
case 'lastMonth':
|
|
32
|
+
return { from: isoDate(new Date(now.getFullYear(), now.getMonth() - 1, 1)), to: isoDate(new Date(now.getFullYear(), now.getMonth(), 0)) }
|
|
33
|
+
case 'last30': {
|
|
34
|
+
const from = new Date(now)
|
|
35
|
+
from.setDate(from.getDate() - 29)
|
|
36
|
+
return { from: isoDate(from), to: isoDate(now) }
|
|
37
|
+
}
|
|
38
|
+
case 'thisYear':
|
|
39
|
+
return { from: isoDate(new Date(now.getFullYear(), 0, 1)), to: isoDate(new Date(now.getFullYear(), 11, 31)) }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
19
43
|
function StatusBadge({ status }: { status: string }) {
|
|
20
44
|
const t = useTranslation()
|
|
21
45
|
const opt = STATUS_OPTIONS.find((o) => o.value === status)
|
|
@@ -87,6 +111,10 @@ export function InvoiceListView({ direction, onNew, onEdit }: {
|
|
|
87
111
|
onEdit?: (id: string) => void
|
|
88
112
|
}) {
|
|
89
113
|
const t = useTranslation()
|
|
114
|
+
// Role-side create gate: hide the "new invoice" affordance (header + empty-state
|
|
115
|
+
// action) when the role lacks `create` — the module's single route only guards read.
|
|
116
|
+
const can = usePermissionOptional()
|
|
117
|
+
const onNewGated = can('financial', 'create') ? onNew : undefined
|
|
90
118
|
const { currency } = useFinancialConfig()
|
|
91
119
|
const provider = useFinancialProvider()
|
|
92
120
|
const invoices = useFinancialStore((s) => s.invoices)
|
|
@@ -100,6 +128,24 @@ export function InvoiceListView({ direction, onNew, onEdit }: {
|
|
|
100
128
|
const [costCenterId, setCostCenterId] = useState('')
|
|
101
129
|
const [accounts, setAccounts] = useState<ChartOfAccountsNode[]>([])
|
|
102
130
|
const [costCenters, setCostCenters] = useState<CostCenter[]>([])
|
|
131
|
+
const [datePreset, setDatePreset] = useState<DatePreset | 'all' | 'custom'>('all')
|
|
132
|
+
const [dateFrom, setDateFrom] = useState('')
|
|
133
|
+
const [dateTo, setDateTo] = useState('')
|
|
134
|
+
|
|
135
|
+
// Open-ended on either side: a missing bound becomes a sentinel the providers can compare against.
|
|
136
|
+
const dateRange = useMemo(() => {
|
|
137
|
+
if (!dateFrom && !dateTo) return undefined
|
|
138
|
+
return { from: dateFrom || '0001-01-01', to: dateTo || '9999-12-31' }
|
|
139
|
+
}, [dateFrom, dateTo])
|
|
140
|
+
|
|
141
|
+
const applyPreset = (value: DatePreset | 'all' | 'custom') => {
|
|
142
|
+
setDatePreset(value)
|
|
143
|
+
if (value === 'all') { setDateFrom(''); setDateTo(''); return }
|
|
144
|
+
if (value === 'custom') return
|
|
145
|
+
const range = presetRange(value)
|
|
146
|
+
setDateFrom(range.from)
|
|
147
|
+
setDateTo(range.to)
|
|
148
|
+
}
|
|
103
149
|
|
|
104
150
|
useEffect(() => {
|
|
105
151
|
let active = true
|
|
@@ -124,8 +170,9 @@ export function InvoiceListView({ direction, onNew, onEdit }: {
|
|
|
124
170
|
search: search || undefined,
|
|
125
171
|
accountId: accountId || undefined,
|
|
126
172
|
costCenterId: costCenterId || undefined,
|
|
173
|
+
dateRange,
|
|
127
174
|
})
|
|
128
|
-
}, [direction, statusFilter, search, accountId, costCenterId])
|
|
175
|
+
}, [direction, statusFilter, search, accountId, costCenterId, dateRange])
|
|
129
176
|
|
|
130
177
|
const filtered = invoices.filter((inv) => inv.direction === direction)
|
|
131
178
|
const listTitle = direction === 'debit' ? t('financial.invoice.accountsPayable') : t('financial.invoice.accountsReceivable')
|
|
@@ -158,8 +205,51 @@ export function InvoiceListView({ direction, onNew, onEdit }: {
|
|
|
158
205
|
title={listTitle}
|
|
159
206
|
subtitle={t('financial.invoice.invoices', { count: filtered.length })}
|
|
160
207
|
/>
|
|
161
|
-
|
|
162
|
-
|
|
208
|
+
<div className="flex flex-wrap items-end gap-3 rounded-lg border bg-muted/20 px-4 py-3">
|
|
209
|
+
<label className="flex min-w-[180px] flex-col gap-1 text-xs font-medium text-muted-foreground">
|
|
210
|
+
{t('financial.invoice.filterPeriod')}
|
|
211
|
+
<select
|
|
212
|
+
className="h-9 rounded-md border bg-background px-3 text-sm text-foreground"
|
|
213
|
+
value={datePreset}
|
|
214
|
+
onChange={(event) => applyPreset(event.target.value as DatePreset | 'all' | 'custom')}
|
|
215
|
+
>
|
|
216
|
+
<option value="all">{t('financial.invoice.filterAllPeriods')}</option>
|
|
217
|
+
<option value="thisMonth">{t('financial.invoice.filterThisMonth')}</option>
|
|
218
|
+
<option value="lastMonth">{t('financial.invoice.filterLastMonth')}</option>
|
|
219
|
+
<option value="last30">{t('financial.invoice.filterLast30')}</option>
|
|
220
|
+
<option value="thisYear">{t('financial.invoice.filterThisYear')}</option>
|
|
221
|
+
<option value="custom">{t('financial.invoice.filterCustom')}</option>
|
|
222
|
+
</select>
|
|
223
|
+
</label>
|
|
224
|
+
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
|
|
225
|
+
{t('financial.invoice.filterFrom')}
|
|
226
|
+
<input
|
|
227
|
+
type="date"
|
|
228
|
+
className="h-9 rounded-md border bg-background px-3 text-sm text-foreground"
|
|
229
|
+
value={dateFrom}
|
|
230
|
+
max={dateTo || undefined}
|
|
231
|
+
onChange={(event) => { setDateFrom(event.target.value); setDatePreset('custom') }}
|
|
232
|
+
/>
|
|
233
|
+
</label>
|
|
234
|
+
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
|
|
235
|
+
{t('financial.invoice.filterTo')}
|
|
236
|
+
<input
|
|
237
|
+
type="date"
|
|
238
|
+
className="h-9 rounded-md border bg-background px-3 text-sm text-foreground"
|
|
239
|
+
value={dateTo}
|
|
240
|
+
min={dateFrom || undefined}
|
|
241
|
+
onChange={(event) => { setDateTo(event.target.value); setDatePreset('custom') }}
|
|
242
|
+
/>
|
|
243
|
+
</label>
|
|
244
|
+
{(dateFrom || dateTo) && (
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
className="h-9 rounded-md px-2 text-xs font-medium text-muted-foreground hover:text-foreground"
|
|
248
|
+
onClick={() => applyPreset('all')}
|
|
249
|
+
>
|
|
250
|
+
{t('financial.invoice.filterClear')}
|
|
251
|
+
</button>
|
|
252
|
+
)}
|
|
163
253
|
{accounts.length > 0 && (
|
|
164
254
|
<label className="flex min-w-[220px] flex-col gap-1 text-xs font-medium text-muted-foreground">
|
|
165
255
|
{t('financial.invoice.filterAccount')}
|
|
@@ -190,8 +280,7 @@ export function InvoiceListView({ direction, onNew, onEdit }: {
|
|
|
190
280
|
</select>
|
|
191
281
|
</label>
|
|
192
282
|
)}
|
|
193
|
-
|
|
194
|
-
)}
|
|
283
|
+
</div>
|
|
195
284
|
<ListView<Invoice>
|
|
196
285
|
columns={columns}
|
|
197
286
|
data={filtered}
|
|
@@ -204,12 +293,12 @@ export function InvoiceListView({ direction, onNew, onEdit }: {
|
|
|
204
293
|
activeTag={statusFilter.length === 1 ? statusFilter[0] : undefined}
|
|
205
294
|
onTagChange={(v) => setStatusFilter(v ? [v as InvoiceStatus] : [])}
|
|
206
295
|
newLabel={t('financial.invoice.new')}
|
|
207
|
-
onNew={
|
|
296
|
+
onNew={onNewGated}
|
|
208
297
|
onRowClick={(row) => onEdit?.(row.id)}
|
|
209
298
|
emptyIcon={FileText}
|
|
210
299
|
emptyMessage={t('financial.invoice.noInvoices')}
|
|
211
|
-
emptyActionLabel={
|
|
212
|
-
onEmptyAction={
|
|
300
|
+
emptyActionLabel={onNewGated ? t('financial.invoice.createFirst') : undefined}
|
|
301
|
+
onEmptyAction={onNewGated}
|
|
213
302
|
/>
|
|
214
303
|
{filtered.length > 0 && !invoicesLoading && (
|
|
215
304
|
<div className="flex items-center justify-end gap-6 rounded-lg border bg-muted/30 px-5 py-3 text-sm">
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
Modal, ModalContent, Button, DatePicker, toast,
|
|
8
8
|
} from '@fayz-ai/ui'
|
|
9
9
|
import { useTranslation } from '@fayz-ai/core'
|
|
10
|
+
import { useLimitGuard, invalidateLimit, usePermissionOptional } from '@fayz-ai/saas'
|
|
10
11
|
import { useFinancialConfig, useFinancialProvider, useFinancialStore, formatCurrency } from '../FinancialContext'
|
|
11
12
|
import type { QuickTransactionType } from '../types'
|
|
12
13
|
import type { ChartOfAccountsNode, BankAccount } from '../types'
|
|
@@ -157,6 +158,8 @@ export function QuickTransactionForm({ open, onOpenChange, defaultType = 'expens
|
|
|
157
158
|
const { currency } = useFinancialConfig()
|
|
158
159
|
const provider = useFinancialProvider()
|
|
159
160
|
const createQuickTransaction = useFinancialStore((s) => s.createQuickTransaction)
|
|
161
|
+
const guardMovements = useLimitGuard('movements_month')
|
|
162
|
+
const can = usePermissionOptional()
|
|
160
163
|
|
|
161
164
|
const [type, setType] = useState<QuickTransactionType>(defaultType)
|
|
162
165
|
const [amount, setAmount] = useState(0)
|
|
@@ -266,12 +269,19 @@ export function QuickTransactionForm({ open, onOpenChange, defaultType = 'expens
|
|
|
266
269
|
: true)
|
|
267
270
|
|
|
268
271
|
async function handleSave() {
|
|
272
|
+
// Defense in depth: never persist for a role without `create`, even if a
|
|
273
|
+
// stray affordance slipped through.
|
|
274
|
+
if (!can('financial', 'create')) return
|
|
269
275
|
if (!canSave) {
|
|
270
276
|
if (type === 'transfer' && accountId && accountId === toAccountId) {
|
|
271
277
|
toast.error(t('financial.transfer.sameAccount'))
|
|
272
278
|
}
|
|
273
279
|
return
|
|
274
280
|
}
|
|
281
|
+
// Plan quantity guard (client-side): a transfer writes two movements (from +
|
|
282
|
+
// to), other kinds one. Guard that many against the monthly cap before the
|
|
283
|
+
// store call — the guard opens the global UpgradeModal when it would exceed.
|
|
284
|
+
if ((await guardMovements(type === 'transfer' ? 2 : 1)) === 'blocked') return
|
|
275
285
|
setSaving(true)
|
|
276
286
|
try {
|
|
277
287
|
await createQuickTransaction({
|
|
@@ -286,6 +296,7 @@ export function QuickTransactionForm({ open, onOpenChange, defaultType = 'expens
|
|
|
286
296
|
recurring: type === 'transfer' ? false : recurring,
|
|
287
297
|
receiptUrl: type === 'transfer' ? undefined : receiptUrl,
|
|
288
298
|
})
|
|
299
|
+
invalidateLimit('movements_month')
|
|
289
300
|
onOpenChange(false)
|
|
290
301
|
} catch {
|
|
291
302
|
// toast handled by store
|