@fayz-ai/plugin-financial 0.8.0 → 0.8.3

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.
@@ -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
- {(accounts.length > 0 || costCenters.length > 0) && (
162
- <div className="flex flex-wrap items-end gap-3 rounded-lg border bg-muted/20 px-4 py-3">
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
- </div>
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={onNew}
296
+ onNew={onNewGated}
208
297
  onRowClick={(row) => onEdit?.(row.id)}
209
298
  emptyIcon={FileText}
210
299
  emptyMessage={t('financial.invoice.noInvoices')}
211
- emptyActionLabel={onNew ? t('financial.invoice.createFirst') : undefined}
212
- onEmptyAction={onNew}
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