@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/data/supabase.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
} from '../types'
|
|
12
12
|
import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
|
|
13
13
|
import { getFinancialTenantId } from '../lib/tenant'
|
|
14
|
+
import { T } from './tables'
|
|
14
15
|
|
|
15
16
|
function getTenantId(): string | undefined {
|
|
16
17
|
// Local override wins; else use the app's active tenant so writes pass RLS.
|
|
@@ -37,7 +38,9 @@ function camelToSnake(obj: Record<string, unknown>): Record<string, unknown> {
|
|
|
37
38
|
function getClients() {
|
|
38
39
|
const supabase = getSupabaseClientOptional() as any
|
|
39
40
|
if (!supabase) throw new Error('Supabase not initialized')
|
|
40
|
-
|
|
41
|
+
// Core tables now live in PUBLIC (legacy dedicated core schema is gone). Both
|
|
42
|
+
// handles are the single public client; `core` retained for call-site readability.
|
|
43
|
+
return { core: supabase, pub: supabase }
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
/** Map order stage to InvoiceStatus for the financial UI */
|
|
@@ -64,7 +67,7 @@ async function sweepOverdue() {
|
|
|
64
67
|
const today = new Date().toISOString().slice(0, 10)
|
|
65
68
|
|
|
66
69
|
// Mark overdue movements
|
|
67
|
-
const { data: overdueMovs } = await pub.from(
|
|
70
|
+
const { data: overdueMovs } = await pub.from(T.movements)
|
|
68
71
|
.update({ status: 'overdue' })
|
|
69
72
|
.in('status', ['pending', 'partial'])
|
|
70
73
|
.lt('due_date', today)
|
|
@@ -74,7 +77,7 @@ async function sweepOverdue() {
|
|
|
74
77
|
if (overdueMovs && overdueMovs.length > 0) {
|
|
75
78
|
const invoiceIds = [...new Set(overdueMovs.map((m: any) => m.invoice_id).filter(Boolean))]
|
|
76
79
|
for (const invId of invoiceIds) {
|
|
77
|
-
const { data: allMovs } = await pub.from(
|
|
80
|
+
const { data: allMovs } = await pub.from(T.movements)
|
|
78
81
|
.select('status')
|
|
79
82
|
.eq('invoice_id', invId)
|
|
80
83
|
.eq('movement_kind', 'bill')
|
|
@@ -95,7 +98,7 @@ async function sweepOverdue() {
|
|
|
95
98
|
// S2 (data-model refactor): paid amount, balance and status are DERIVED from the
|
|
96
99
|
// append-only ledger via v_invoice_balances — the single source of truth — not
|
|
97
100
|
// from stored order.stage / metadata.paidAmount (which drift). See
|
|
98
|
-
// docs/
|
|
101
|
+
// docs/DATA-MODEL.md.
|
|
99
102
|
async function fetchInvoiceBalances(
|
|
100
103
|
pub: any,
|
|
101
104
|
ids: string[],
|
|
@@ -137,7 +140,7 @@ async function findOrderIdsByItemMetadata(
|
|
|
137
140
|
/** Card/MDR fee percent for a configured payment method (0 when none / not a fee method). */
|
|
138
141
|
async function getMethodFeePct(pub: any, paymentMethodId?: string): Promise<number> {
|
|
139
142
|
if (!paymentMethodId) return 0
|
|
140
|
-
const { data } = await pub.from(
|
|
143
|
+
const { data } = await pub.from(T.paymentMethods).select('discount_value').eq('id', paymentMethodId).single()
|
|
141
144
|
return Number(data?.discount_value) || 0
|
|
142
145
|
}
|
|
143
146
|
|
|
@@ -179,7 +182,12 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
179
182
|
}
|
|
180
183
|
if (query.contactId) qb = qb.eq('party_id', query.contactId)
|
|
181
184
|
if (query.search) qb = qb.ilike('notes', `%${query.search}%`)
|
|
182
|
-
|
|
185
|
+
// `to` is a plain date from the UI filter — widen it to end-of-day so the
|
|
186
|
+
// last day of the range isn't cut off at midnight.
|
|
187
|
+
if (query.dateRange) {
|
|
188
|
+
const to = query.dateRange.to.length === 10 ? `${query.dateRange.to}T23:59:59.999` : query.dateRange.to
|
|
189
|
+
qb = qb.gte('created_at', query.dateRange.from).lte('created_at', to)
|
|
190
|
+
}
|
|
183
191
|
const page = query.page ?? 1
|
|
184
192
|
const pageSize = query.pageSize ?? 50
|
|
185
193
|
qb = qb.range((page - 1) * pageSize, page * pageSize - 1).order('created_at', { ascending: false })
|
|
@@ -220,7 +228,7 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
220
228
|
const meta = data.metadata ?? {}
|
|
221
229
|
// Paid amount + status are DERIVED from the ledger view (single source of truth).
|
|
222
230
|
const balance = (await fetchInvoiceBalances(pub, [id])).get(id)
|
|
223
|
-
const { data: movs } = await pub.from(
|
|
231
|
+
const { data: movs } = await pub.from(T.movements)
|
|
224
232
|
.select('paid_amount')
|
|
225
233
|
.eq('invoice_id', id)
|
|
226
234
|
.eq('movement_kind', 'bill')
|
|
@@ -303,7 +311,7 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
303
311
|
|
|
304
312
|
// Create installment movements
|
|
305
313
|
if (order && input.installments.length > 0) {
|
|
306
|
-
await pub.from(
|
|
314
|
+
await pub.from(T.movements).insert(
|
|
307
315
|
input.installments.map((inst, i) => ({
|
|
308
316
|
tenant_id: tenantId, invoice_id: order.id,
|
|
309
317
|
direction: input.direction, movement_kind: inst.movementKind,
|
|
@@ -335,13 +343,13 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
335
343
|
async cancelInvoice(id: string): Promise<void> {
|
|
336
344
|
const { core, pub } = getClients()
|
|
337
345
|
await core.from('orders').update({ status: 'cancelled' }).eq('id', id)
|
|
338
|
-
await pub.from(
|
|
346
|
+
await pub.from(T.movements).update({ status: 'cancelled' }).eq('invoice_id', id).eq('status', 'pending')
|
|
339
347
|
},
|
|
340
348
|
|
|
341
349
|
// --- Movements (public.financial_movements) ---
|
|
342
350
|
async getMovements(query: MovementQuery): Promise<PaginatedResult<FinancialMovement>> {
|
|
343
351
|
const { core, pub } = getClients()
|
|
344
|
-
let qb = pub.from(
|
|
352
|
+
let qb = pub.from(T.movements).select('*', { count: 'exact' })
|
|
345
353
|
if (query.direction) qb = qb.eq('direction', query.direction)
|
|
346
354
|
if (query.status) {
|
|
347
355
|
const statuses = Array.isArray(query.status) ? query.status : [query.status]
|
|
@@ -359,7 +367,7 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
359
367
|
|
|
360
368
|
async payMovement(input: PayMovementInput): Promise<FinancialMovement> {
|
|
361
369
|
const { core, pub } = getClients()
|
|
362
|
-
const { data: mov } = await pub.from(
|
|
370
|
+
const { data: mov } = await pub.from(T.movements).select('*').eq('id', input.movementId).single()
|
|
363
371
|
if (!mov) throw new Error('Movement not found')
|
|
364
372
|
const tenantId = mov.tenant_id ?? getTenantId()
|
|
365
373
|
|
|
@@ -389,11 +397,11 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
389
397
|
card_installments: input.cardInstallments ?? null,
|
|
390
398
|
notes: input.notes ?? null,
|
|
391
399
|
}
|
|
392
|
-
let { data, error } = await pub.from(
|
|
400
|
+
let { data, error } = await pub.from(T.movements).insert(paymentRow).select().single()
|
|
393
401
|
// Graceful fallback if the fee_amount migration hasn't been applied yet.
|
|
394
402
|
if (error && /fee_amount/i.test(error.message ?? '')) {
|
|
395
403
|
const { fee_amount, ...rest } = paymentRow
|
|
396
|
-
;({ data, error } = await pub.from(
|
|
404
|
+
;({ data, error } = await pub.from(T.movements).insert(rest).select().single())
|
|
397
405
|
}
|
|
398
406
|
if (error) throw error
|
|
399
407
|
|
|
@@ -404,13 +412,13 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
404
412
|
const billStatus = newPaid >= mov.amount ? 'paid' : 'partial'
|
|
405
413
|
// Clear the bill's cash fields so it stops being a cash event (the payment row is now
|
|
406
414
|
// the cash event) — prevents double-counting legacy in-place-paid bills.
|
|
407
|
-
await pub.from(
|
|
415
|
+
await pub.from(T.movements)
|
|
408
416
|
.update({ paid_amount: newPaid, status: billStatus, payment_date: null, bank_account_id: null })
|
|
409
417
|
.eq('id', mov.id)
|
|
410
418
|
|
|
411
419
|
// 3. Update invoice (order) status based on its bills
|
|
412
420
|
if (mov.invoice_id) {
|
|
413
|
-
const { data: allMovs } = await pub.from(
|
|
421
|
+
const { data: allMovs } = await pub.from(T.movements)
|
|
414
422
|
.select('status, amount, paid_amount')
|
|
415
423
|
.eq('invoice_id', mov.invoice_id)
|
|
416
424
|
.eq('movement_kind', 'bill')
|
|
@@ -423,7 +431,7 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
423
431
|
const { data: order } = await core.from('orders').select('metadata').eq('id', mov.invoice_id).single()
|
|
424
432
|
const meta = (order?.metadata as any) ?? {}
|
|
425
433
|
const invoiceStage = allPaid ? 'paid' : anyPaid ? 'partial' : 'invoiced'
|
|
426
|
-
// Only update financial order status. Agenda status lives on
|
|
434
|
+
// Only update financial order status. Agenda status lives on public.appointments.
|
|
427
435
|
await core.from('orders').update({
|
|
428
436
|
status: invoiceStage,
|
|
429
437
|
metadata: { ...meta, paidAmount: totalPaid },
|
|
@@ -435,7 +443,7 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
435
443
|
|
|
436
444
|
async cancelMovement(id: string): Promise<void> {
|
|
437
445
|
const { core, pub } = getClients()
|
|
438
|
-
await pub.from(
|
|
446
|
+
await pub.from(T.movements).update({ status: 'cancelled' }).eq('id', id)
|
|
439
447
|
},
|
|
440
448
|
|
|
441
449
|
// --- Transfers (between accounts) ---
|
|
@@ -472,11 +480,11 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
472
480
|
metadata: { transferId, transferRole: 'in', counterAccountId: input.fromAccountId },
|
|
473
481
|
},
|
|
474
482
|
]
|
|
475
|
-
let { data, error } = await pub.from(
|
|
483
|
+
let { data, error } = await pub.from(T.movements).insert(rowsToInsert).select('id, direction')
|
|
476
484
|
// Graceful fallback if the fee_amount migration hasn't been applied yet.
|
|
477
485
|
if (error && /fee_amount/i.test(error.message ?? '')) {
|
|
478
486
|
const stripped = rowsToInsert.map(({ fee_amount, ...rest }) => rest)
|
|
479
|
-
;({ data, error } = await pub.from(
|
|
487
|
+
;({ data, error } = await pub.from(T.movements).insert(stripped).select('id, direction'))
|
|
480
488
|
}
|
|
481
489
|
if (error) throw error
|
|
482
490
|
const rows = (data ?? []) as Array<{ id: string; direction: string }>
|
|
@@ -488,14 +496,14 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
488
496
|
// --- Bank Accounts ---
|
|
489
497
|
async getBankAccounts(): Promise<BankAccount[]> {
|
|
490
498
|
const { core, pub } = getClients()
|
|
491
|
-
const { data } = await pub.from(
|
|
499
|
+
const { data } = await pub.from(T.bankAccounts).select('*').eq('is_active', true).order('name')
|
|
492
500
|
return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as BankAccount)
|
|
493
501
|
},
|
|
494
502
|
|
|
495
503
|
async createBankAccount(input: CreateBankAccountInput): Promise<BankAccount> {
|
|
496
504
|
const { core, pub } = getClients()
|
|
497
505
|
const tenantId = getTenantId()
|
|
498
|
-
const { data } = await pub.from(
|
|
506
|
+
const { data } = await pub.from(T.bankAccounts).insert({
|
|
499
507
|
...camelToSnake(input as any), tenant_id: tenantId, current_balance: input.initialBalance ?? 0,
|
|
500
508
|
}).select().single()
|
|
501
509
|
return snakeToCamel(data!) as unknown as BankAccount
|
|
@@ -505,14 +513,14 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
505
513
|
const { core, pub } = getClients()
|
|
506
514
|
const row = camelToSnake(partial as any)
|
|
507
515
|
delete row.id; delete row.tenant_id
|
|
508
|
-
const { data } = await pub.from(
|
|
516
|
+
const { data } = await pub.from(T.bankAccounts).update(row).eq('id', id).select().single()
|
|
509
517
|
return snakeToCamel(data!) as unknown as BankAccount
|
|
510
518
|
},
|
|
511
519
|
|
|
512
520
|
// --- Cash Sessions ---
|
|
513
521
|
async getCashSessions(bankAccountId?: string): Promise<CashSession[]> {
|
|
514
522
|
const { core, pub } = getClients()
|
|
515
|
-
let qb = pub.from(
|
|
523
|
+
let qb = pub.from(T.cashRegisterSessions).select('*')
|
|
516
524
|
if (bankAccountId) qb = qb.eq('bank_account_id', bankAccountId)
|
|
517
525
|
const { data } = await qb.order('opened_at', { ascending: false })
|
|
518
526
|
return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as CashSession)
|
|
@@ -520,14 +528,14 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
520
528
|
|
|
521
529
|
async getOpenSession(bankAccountId: string): Promise<CashSession | null> {
|
|
522
530
|
const { core, pub } = getClients()
|
|
523
|
-
const { data } = await pub.from(
|
|
531
|
+
const { data } = await pub.from(T.cashRegisterSessions).select('*').eq('bank_account_id', bankAccountId).eq('status', 'open').single()
|
|
524
532
|
return data ? snakeToCamel(data) as unknown as CashSession : null
|
|
525
533
|
},
|
|
526
534
|
|
|
527
535
|
async openCashSession(input: OpenCashSessionInput): Promise<CashSession> {
|
|
528
536
|
const { core, pub } = getClients()
|
|
529
537
|
const tenantId = getTenantId()
|
|
530
|
-
const { data } = await pub.from(
|
|
538
|
+
const { data } = await pub.from(T.cashRegisterSessions).insert({
|
|
531
539
|
tenant_id: tenantId, bank_account_id: input.bankAccountId,
|
|
532
540
|
status: 'open', opening_balance: input.openingBalance,
|
|
533
541
|
opened_by_user_id: input.openedByUserId, opened_by_name: input.openedByName,
|
|
@@ -538,7 +546,7 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
538
546
|
|
|
539
547
|
async closeCashSession(input: CloseCashSessionInput): Promise<CashSession> {
|
|
540
548
|
const { core, pub } = getClients()
|
|
541
|
-
const { data } = await pub.from(
|
|
549
|
+
const { data } = await pub.from(T.cashRegisterSessions).update({
|
|
542
550
|
status: 'closed', closing_balance: input.closingBalance,
|
|
543
551
|
closed_at: new Date().toISOString(),
|
|
544
552
|
closed_by_user_id: input.closedByUserId, closed_by_name: input.closedByName,
|
|
@@ -549,8 +557,8 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
549
557
|
|
|
550
558
|
async getCashSessionSummary(sessionId: string): Promise<CashSessionSummary> {
|
|
551
559
|
const { core, pub } = getClients()
|
|
552
|
-
const session = (await pub.from(
|
|
553
|
-
const { data: movs } = await pub.from(
|
|
560
|
+
const session = (await pub.from(T.cashRegisterSessions).select('*').eq('id', sessionId).single()).data
|
|
561
|
+
const { data: movs } = await pub.from(T.movements).select('direction, paid_amount').eq('cash_session_id', sessionId).eq('status', 'paid')
|
|
554
562
|
const movements = movs ?? []
|
|
555
563
|
return {
|
|
556
564
|
session: snakeToCamel(session!) as unknown as CashSession,
|
|
@@ -563,26 +571,26 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
563
571
|
// --- Payment Methods ---
|
|
564
572
|
async getPaymentMethods(): Promise<PaymentMethod[]> {
|
|
565
573
|
const { core, pub } = getClients()
|
|
566
|
-
const { data } = await pub.from(
|
|
574
|
+
const { data } = await pub.from(T.paymentMethods).select('*').eq('is_active', true).order('name')
|
|
567
575
|
return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as PaymentMethod)
|
|
568
576
|
},
|
|
569
577
|
|
|
570
578
|
async getPaymentMethodTypes(): Promise<PaymentMethodType[]> {
|
|
571
579
|
const { core, pub } = getClients()
|
|
572
|
-
const { data } = await pub.from(
|
|
580
|
+
const { data } = await pub.from(T.paymentMethodTypes).select('*').eq('is_active', true).order('name')
|
|
573
581
|
return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as PaymentMethodType)
|
|
574
582
|
},
|
|
575
583
|
|
|
576
584
|
// --- Chart of Accounts ---
|
|
577
585
|
async getChartOfAccounts(): Promise<ChartOfAccountsNode[]> {
|
|
578
586
|
const { core, pub } = getClients()
|
|
579
|
-
const { data } = await pub.from(
|
|
587
|
+
const { data } = await pub.from(T.chartOfAccounts).select('*').eq('is_active', true).order('code')
|
|
580
588
|
return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as ChartOfAccountsNode)
|
|
581
589
|
},
|
|
582
590
|
|
|
583
591
|
async getCostCenters(): Promise<CostCenter[]> {
|
|
584
592
|
const { core, pub } = getClients()
|
|
585
|
-
const { data } = await pub.from(
|
|
593
|
+
const { data } = await pub.from(T.costCenters).select('*').eq('is_active', true).order('code')
|
|
586
594
|
return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as CostCenter)
|
|
587
595
|
},
|
|
588
596
|
|
|
@@ -604,7 +612,7 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
604
612
|
|
|
605
613
|
// 1. Resolve in-scope accounts. No bankAccountId => consolidated: ALL realized cash
|
|
606
614
|
// (including movements with no bank_account_id, e.g. cash/card payments not tied to an account).
|
|
607
|
-
const { data: acctRows } = await pub.from(
|
|
615
|
+
const { data: acctRows } = await pub.from(T.bankAccounts).select('id, name, initial_balance, is_active')
|
|
608
616
|
const allAccounts = (acctRows ?? []) as any[]
|
|
609
617
|
const consolidated = !query.bankAccountId
|
|
610
618
|
const scopeAccounts = consolidated
|
|
@@ -626,7 +634,7 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
626
634
|
|
|
627
635
|
// 2. Opening balance = Σ initial_balance + prior net (payment_date < from).
|
|
628
636
|
// Select '*' so this still works before the fee_amount migration lands (fee → 0).
|
|
629
|
-
const { data: priorRows } = await scopeFilter(pub.from(
|
|
637
|
+
const { data: priorRows } = await scopeFilter(pub.from(T.movements)
|
|
630
638
|
.select('*')
|
|
631
639
|
.neq('status', 'cancelled')
|
|
632
640
|
.lt('payment_date', query.dateRange.from))
|
|
@@ -634,7 +642,7 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
634
642
|
const openingBalance = round2(initialSum + priorNet)
|
|
635
643
|
|
|
636
644
|
// 3. In-range realized rows.
|
|
637
|
-
const { data: movs } = await scopeFilter(pub.from(
|
|
645
|
+
const { data: movs } = await scopeFilter(pub.from(T.movements)
|
|
638
646
|
.select('*')
|
|
639
647
|
.neq('status', 'cancelled')
|
|
640
648
|
.gte('payment_date', query.dateRange.from).lte('payment_date', query.dateRange.to)
|
|
@@ -687,7 +695,7 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
687
695
|
// extract row can link to the internal transaction it was conciliated with.
|
|
688
696
|
const matchedIds = [...new Set(entries.map((e) => e.movement.matchedMovementId).filter(Boolean))] as string[]
|
|
689
697
|
if (matchedIds.length) {
|
|
690
|
-
const { data: matched } = await pub.from(
|
|
698
|
+
const { data: matched } = await pub.from(T.movements).select('id, invoice_id, notes').in('id', matchedIds)
|
|
691
699
|
const matchedById = new Map<string, any>((matched ?? []).map((m: any) => [m.id, m]))
|
|
692
700
|
const matchedInvoiceIds = [...new Set((matched ?? []).map((m: any) => m.invoice_id).filter(Boolean))] as string[]
|
|
693
701
|
const invById = new Map<string, any>()
|
|
@@ -711,7 +719,7 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
711
719
|
}
|
|
712
720
|
|
|
713
721
|
// 6. Account current balance (ledger as-of-today).
|
|
714
|
-
const { data: todayRows } = await scopeFilter(pub.from(
|
|
722
|
+
const { data: todayRows } = await scopeFilter(pub.from(T.movements)
|
|
715
723
|
.select('*')
|
|
716
724
|
.neq('status', 'cancelled')
|
|
717
725
|
.lte('payment_date', today))
|
|
@@ -735,10 +743,10 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
735
743
|
// Sweep overdue on first load (background, non-blocking)
|
|
736
744
|
sweepOverdue()
|
|
737
745
|
const { core, pub } = getClients()
|
|
738
|
-
const { data: accounts } = await pub.from(
|
|
746
|
+
const { data: accounts } = await pub.from(T.bankAccounts).select('current_balance').eq('is_active', true)
|
|
739
747
|
const totalBalance = (accounts ?? []).reduce((s: number, a: any) => s + (a.current_balance ?? 0), 0)
|
|
740
748
|
|
|
741
|
-
const { data: movs } = await pub.from(
|
|
749
|
+
const { data: movs } = await pub.from(T.movements).select('direction, movement_kind, amount, paid_amount, status, due_date, payment_date')
|
|
742
750
|
const allMovs = movs ?? []
|
|
743
751
|
const unpaid = allMovs.filter((m: any) => ['pending', 'partial', 'overdue'].includes(m.status))
|
|
744
752
|
const today = new Date().toISOString().slice(0, 10)
|
|
@@ -778,7 +786,7 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
778
786
|
const tenantId = getTenantId()
|
|
779
787
|
let imported = 0, duplicates = 0
|
|
780
788
|
for (const line of input.lines) {
|
|
781
|
-
const { error } = await pub.from(
|
|
789
|
+
const { error } = await pub.from(T.movements).insert({
|
|
782
790
|
tenant_id: tenantId,
|
|
783
791
|
direction: line.direction,
|
|
784
792
|
movement_kind: 'payment',
|
|
@@ -803,7 +811,7 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
803
811
|
|
|
804
812
|
async getUnreconciled(query) {
|
|
805
813
|
const { pub } = getClients()
|
|
806
|
-
let qb = pub.from(
|
|
814
|
+
let qb = pub.from(T.movements).select('*')
|
|
807
815
|
.not('external_source', 'is', null)
|
|
808
816
|
.is('reconciled_at', null)
|
|
809
817
|
if (query?.bankAccountId) qb = qb.eq('bank_account_id', query.bankAccountId)
|
|
@@ -814,13 +822,13 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
814
822
|
|
|
815
823
|
async suggestReconciliation(bankMovementId) {
|
|
816
824
|
const { core, pub } = getClients()
|
|
817
|
-
const { data: line } = await pub.from(
|
|
825
|
+
const { data: line } = await pub.from(T.movements).select('*').eq('id', bankMovementId).single()
|
|
818
826
|
if (!line) return []
|
|
819
827
|
const amount = Number(line.amount) || 0
|
|
820
828
|
const lineDate = line.payment_date ?? line.due_date
|
|
821
829
|
// Candidates: app-native (non-imported), same direction, not reconciled, amount within ±2%.
|
|
822
830
|
const tol = Math.max(0.01, round2(amount * 0.02))
|
|
823
|
-
const { data } = await pub.from(
|
|
831
|
+
const { data } = await pub.from(T.movements).select('*')
|
|
824
832
|
.is('external_source', null)
|
|
825
833
|
.is('reconciled_at', null)
|
|
826
834
|
.eq('direction', line.direction)
|
|
@@ -857,22 +865,22 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
857
865
|
async reconcileMovement(input) {
|
|
858
866
|
const { pub } = getClients()
|
|
859
867
|
const now = new Date().toISOString()
|
|
860
|
-
await pub.from(
|
|
868
|
+
await pub.from(T.movements).update({
|
|
861
869
|
reconciled_at: now,
|
|
862
870
|
matched_movement_id: input.matchedMovementId ?? null,
|
|
863
871
|
}).eq('id', input.bankMovementId)
|
|
864
872
|
if (input.matchedMovementId) {
|
|
865
873
|
// The internal movement is now settled+reconciled by the bank line.
|
|
866
|
-
await pub.from(
|
|
874
|
+
await pub.from(T.movements).update({ reconciled_at: now }).eq('id', input.matchedMovementId)
|
|
867
875
|
}
|
|
868
876
|
},
|
|
869
877
|
|
|
870
878
|
async unreconcileMovement(bankMovementId) {
|
|
871
879
|
const { pub } = getClients()
|
|
872
|
-
const { data: line } = await pub.from(
|
|
873
|
-
await pub.from(
|
|
880
|
+
const { data: line } = await pub.from(T.movements).select('matched_movement_id').eq('id', bankMovementId).single()
|
|
881
|
+
await pub.from(T.movements).update({ reconciled_at: null, matched_movement_id: null }).eq('id', bankMovementId)
|
|
874
882
|
if (line?.matched_movement_id) {
|
|
875
|
-
await pub.from(
|
|
883
|
+
await pub.from(T.movements).update({ reconciled_at: null }).eq('id', line.matched_movement_id)
|
|
876
884
|
}
|
|
877
885
|
},
|
|
878
886
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Central physical-table-name registry for plugin-financial. Providers import T so
|
|
2
|
+
// the plg_financial_* strings live in one place.
|
|
3
|
+
export const T = {
|
|
4
|
+
movements: 'plg_financial_movements',
|
|
5
|
+
chartOfAccounts: 'plg_financial_chart_of_accounts',
|
|
6
|
+
bankAccounts: 'plg_financial_bank_accounts',
|
|
7
|
+
cardBrands: 'plg_financial_card_brands',
|
|
8
|
+
costCenters: 'plg_financial_cost_centers',
|
|
9
|
+
cashRegisterSessions: 'plg_financial_cash_register_sessions',
|
|
10
|
+
paymentMethods: 'plg_financial_payment_methods',
|
|
11
|
+
paymentMethodTypes: 'plg_financial_payment_method_types',
|
|
12
|
+
} as const
|
package/src/index.ts
CHANGED
|
@@ -121,6 +121,22 @@ export interface FinancialPluginOptions {
|
|
|
121
121
|
|
|
122
122
|
/** Callback when user clicks a booking link on an invoice. Receives the order ID. */
|
|
123
123
|
onBookingClick?: (orderId: string) => void
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Which internal-nav links this APP gates, as `nav item id → feature id`.
|
|
127
|
+
* Empty by default: the plugin ships no opinion about what is paid — packaging
|
|
128
|
+
* is the app's decision, declared alongside its `permissions.ts` (RBAC) and
|
|
129
|
+
* `billing.ts` (plan entitlements). An id absent from this map is never hidden
|
|
130
|
+
* and never gated, in the nav OR in the content.
|
|
131
|
+
*
|
|
132
|
+
* {@link FINANCIAL_NAV_FEATURES} is an opt-in preset of the `fin_*` ids used
|
|
133
|
+
* by the reference apps — spread the entries you actually want:
|
|
134
|
+
*
|
|
135
|
+
* ```ts
|
|
136
|
+
* navFeatureMap: { reconciliation: FINANCIAL_NAV_FEATURES.reconciliation }
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
navFeatureMap?: Record<string, string>
|
|
124
140
|
}
|
|
125
141
|
|
|
126
142
|
// ---------------------------------------------------------------------------
|
|
@@ -150,6 +166,23 @@ const DEFAULT_LABELS: FinancialPluginLabels = {
|
|
|
150
166
|
|
|
151
167
|
const DEFAULT_CURRENCY = { code: 'BRL', locale: 'pt-BR', symbol: 'R$' }
|
|
152
168
|
|
|
169
|
+
/**
|
|
170
|
+
* OPT-IN preset of feature ids for the financial sub-modules — a naming
|
|
171
|
+
* convention offered to apps, NOT a default. Nothing here is applied unless an
|
|
172
|
+
* app passes the entries it wants through `options.navFeatureMap`, because what
|
|
173
|
+
* is paid is a product decision that belongs to the app (its `permissions.ts` +
|
|
174
|
+
* `billing.ts`), never to the plugin.
|
|
175
|
+
*/
|
|
176
|
+
export const FINANCIAL_NAV_FEATURES = {
|
|
177
|
+
reconciliation: 'fin_reconciliation',
|
|
178
|
+
cards: 'fin_cards',
|
|
179
|
+
commissions: 'fin_commissions',
|
|
180
|
+
statements: 'fin_statements',
|
|
181
|
+
receivables: 'fin_receivables',
|
|
182
|
+
payables: 'fin_payables',
|
|
183
|
+
'cash-registers': 'fin_cash_registers',
|
|
184
|
+
} as const satisfies Record<string, string>
|
|
185
|
+
|
|
153
186
|
const DEFAULT_ITEM_TYPES: ItemTypeOption[] = [
|
|
154
187
|
{ value: 'service', label: 'Service', icon: 'Briefcase' },
|
|
155
188
|
{ value: 'product', label: 'Product', icon: 'Package' },
|
|
@@ -186,6 +219,7 @@ function resolveConfig(options?: FinancialPluginOptions): ResolvedFinancialConfi
|
|
|
186
219
|
entityLookups: options?.entityLookups ?? {},
|
|
187
220
|
contactLookup: options?.contactLookup,
|
|
188
221
|
onBookingClick: options?.onBookingClick,
|
|
222
|
+
navFeatureMap: { ...options?.navFeatureMap },
|
|
189
223
|
}
|
|
190
224
|
}
|
|
191
225
|
|
|
@@ -225,6 +259,71 @@ export function createFinancialPlugin(options?: FinancialPluginOptions): PluginM
|
|
|
225
259
|
...(config.modules.payables ? [{ id: 'financial.payables', label: config.labels.payables, group: config.labels.pageTitle }] : []),
|
|
226
260
|
...(config.modules.commissions ? [{ id: 'financial.commissions', label: config.labels.commissions, group: config.labels.pageTitle }] : []),
|
|
227
261
|
],
|
|
262
|
+
// Recurring monthly quota — counts financial movement rows created this
|
|
263
|
+
// month (invoices write one per installment; quick transactions one, or two
|
|
264
|
+
// for a transfer).
|
|
265
|
+
declaredLimits: [
|
|
266
|
+
{ key: 'movements_month', label: 'Financial movements this month', table: 'plg_financial_movements', period: 'month' },
|
|
267
|
+
],
|
|
268
|
+
queryEntities: [
|
|
269
|
+
{
|
|
270
|
+
key: 'financial:invoices',
|
|
271
|
+
entity: {
|
|
272
|
+
name: 'Invoice',
|
|
273
|
+
namePlural: 'Invoices (receipts REC- / payables PAG-)',
|
|
274
|
+
icon: 'Receipt',
|
|
275
|
+
permission: { feature: 'financial', action: 'read' },
|
|
276
|
+
fields: [
|
|
277
|
+
{ key: 'referenceNumber', label: 'Reference (REC-/PAG-)', type: 'text', searchable: true },
|
|
278
|
+
{ key: 'flow', label: 'Flow (receivable/payable)', type: 'text' },
|
|
279
|
+
{ key: 'amount', label: 'Amount', type: 'number' },
|
|
280
|
+
{ key: 'paid', label: 'Paid', type: 'number' },
|
|
281
|
+
{ key: 'balance', label: 'Balance', type: 'number' },
|
|
282
|
+
{ key: 'status', label: 'Status', type: 'text' },
|
|
283
|
+
{ key: 'createdAt', label: 'Created at', type: 'text' },
|
|
284
|
+
],
|
|
285
|
+
data: {
|
|
286
|
+
table: 'v_invoice_balances',
|
|
287
|
+
tenantScoped: true,
|
|
288
|
+
searchColumns: ['reference_number'],
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
key: 'financial:movements',
|
|
294
|
+
entity: {
|
|
295
|
+
name: 'Financial movement',
|
|
296
|
+
namePlural: 'Financial movements (installment ledger)',
|
|
297
|
+
icon: 'Banknote',
|
|
298
|
+
permission: { feature: 'financial', action: 'read' },
|
|
299
|
+
fields: [
|
|
300
|
+
{ key: 'direction', label: 'Direction', type: 'text' },
|
|
301
|
+
{ key: 'movementKind', label: 'Kind (bill/payment)', type: 'text' },
|
|
302
|
+
{ key: 'amount', label: 'Amount', type: 'number' },
|
|
303
|
+
{ key: 'paidAmount', label: 'Paid amount', type: 'number' },
|
|
304
|
+
{ key: 'status', label: 'Status', type: 'text' },
|
|
305
|
+
{ key: 'dueDate', label: 'Due date', type: 'text' },
|
|
306
|
+
{ key: 'paymentDate', label: 'Payment date', type: 'text' },
|
|
307
|
+
{ key: 'installmentNumber', label: 'Installment', type: 'number' },
|
|
308
|
+
{ key: 'cardInstallments', label: 'Card installments', type: 'number' },
|
|
309
|
+
{ key: 'createdAt', label: 'Created at', type: 'text' },
|
|
310
|
+
],
|
|
311
|
+
data: {
|
|
312
|
+
table: 'plg_financial_movements',
|
|
313
|
+
tenantScoped: true,
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
declaredRpcs: [
|
|
319
|
+
{
|
|
320
|
+
name: 'agent_financial_mark_payment_received',
|
|
321
|
+
kind: 'write' as const,
|
|
322
|
+
description:
|
|
323
|
+
'Guarded settle: invoices the order via fn_invoice_from_order (installments for credit card) and marks the receivable paid, audited with the acting user.',
|
|
324
|
+
audits: true,
|
|
325
|
+
},
|
|
326
|
+
],
|
|
228
327
|
navigation: [
|
|
229
328
|
{
|
|
230
329
|
section: options?.navSection ?? 'main',
|
|
@@ -278,6 +377,7 @@ export function createFinancialPlugin(options?: FinancialPluginOptions): PluginM
|
|
|
278
377
|
description: 'Creates a new invoice/payable for a contact.',
|
|
279
378
|
icon: 'FileText',
|
|
280
379
|
mode: 'persist' as const,
|
|
380
|
+
limitKey: 'movements_month',
|
|
281
381
|
category: 'Finance',
|
|
282
382
|
parameters: {
|
|
283
383
|
type: 'object' as const,
|
|
@@ -293,6 +393,34 @@ export function createFinancialPlugin(options?: FinancialPluginOptions): PluginM
|
|
|
293
393
|
],
|
|
294
394
|
permission: { feature: 'financial', action: 'create' as const },
|
|
295
395
|
},
|
|
396
|
+
{
|
|
397
|
+
id: 'financial.mark-payment-received',
|
|
398
|
+
name: 'markPaymentReceived',
|
|
399
|
+
description:
|
|
400
|
+
'Marks an order/appointment as paid: invoices it (with installments for credit card, e.g. "cartão 2x") and settles the receivable. Resolve the booking or order id via search tools first.',
|
|
401
|
+
icon: 'BadgeDollarSign',
|
|
402
|
+
mode: 'persist' as const,
|
|
403
|
+
execution: { plane: 'server' as const, kind: 'rpc' as const, rpc: 'agent_financial_mark_payment_received' },
|
|
404
|
+
category: 'Finance',
|
|
405
|
+
parameters: {
|
|
406
|
+
type: 'object' as const,
|
|
407
|
+
properties: {
|
|
408
|
+
booking_id: { type: 'string' as const, description: 'Appointment id (alternative to order_id)' },
|
|
409
|
+
order_id: { type: 'string' as const, description: 'Order id' },
|
|
410
|
+
method: {
|
|
411
|
+
type: 'string' as const,
|
|
412
|
+
description: 'Payment method',
|
|
413
|
+
enum: ['cash', 'pix', 'credit_card', 'debit_card', 'transfer'],
|
|
414
|
+
},
|
|
415
|
+
installments: { type: 'number' as const, description: 'Credit-card installments (1-12), e.g. 2 for "cartão 2x"' },
|
|
416
|
+
},
|
|
417
|
+
required: ['method'],
|
|
418
|
+
},
|
|
419
|
+
suggestions: [
|
|
420
|
+
{ label: 'Marcar o atendimento como pago no cartão em 2x' },
|
|
421
|
+
],
|
|
422
|
+
permission: { feature: 'financial.receivables', action: 'update' as const },
|
|
423
|
+
},
|
|
296
424
|
{
|
|
297
425
|
id: 'financial.list-payables',
|
|
298
426
|
name: 'listPayables',
|
package/src/locales/en.ts
CHANGED
|
@@ -142,6 +142,16 @@ export const en: Record<string, string> = {
|
|
|
142
142
|
"financial.invoice.filterAllAccounts": "All accounts",
|
|
143
143
|
"financial.invoice.filterCostCenter": "Cost center",
|
|
144
144
|
"financial.invoice.filterAllCostCenters": "All cost centers",
|
|
145
|
+
"financial.invoice.filterPeriod": "Period",
|
|
146
|
+
"financial.invoice.filterAllPeriods": "All time",
|
|
147
|
+
"financial.invoice.filterThisMonth": "This month",
|
|
148
|
+
"financial.invoice.filterLastMonth": "Last month",
|
|
149
|
+
"financial.invoice.filterLast30": "Last 30 days",
|
|
150
|
+
"financial.invoice.filterThisYear": "This year",
|
|
151
|
+
"financial.invoice.filterCustom": "Custom",
|
|
152
|
+
"financial.invoice.filterFrom": "From",
|
|
153
|
+
"financial.invoice.filterTo": "To",
|
|
154
|
+
"financial.invoice.filterClear": "Clear",
|
|
145
155
|
"financial.reconciliation.title": "Reconciliation",
|
|
146
156
|
"financial.reconciliation.subtitle": "Match imported bank lines to your movements",
|
|
147
157
|
"financial.reconciliation.pending": "{{count}} to reconcile",
|
package/src/locales/pt-BR.ts
CHANGED
|
@@ -142,6 +142,16 @@ export const ptBR: Record<string, string> = {
|
|
|
142
142
|
"financial.invoice.filterAllAccounts": "Todos os planos",
|
|
143
143
|
"financial.invoice.filterCostCenter": "Centro de custo",
|
|
144
144
|
"financial.invoice.filterAllCostCenters": "Todos os centros",
|
|
145
|
+
"financial.invoice.filterPeriod": "Período",
|
|
146
|
+
"financial.invoice.filterAllPeriods": "Todo o período",
|
|
147
|
+
"financial.invoice.filterThisMonth": "Este mês",
|
|
148
|
+
"financial.invoice.filterLastMonth": "Mês passado",
|
|
149
|
+
"financial.invoice.filterLast30": "Últimos 30 dias",
|
|
150
|
+
"financial.invoice.filterThisYear": "Este ano",
|
|
151
|
+
"financial.invoice.filterCustom": "Personalizado",
|
|
152
|
+
"financial.invoice.filterFrom": "De",
|
|
153
|
+
"financial.invoice.filterTo": "Até",
|
|
154
|
+
"financial.invoice.filterClear": "Limpar",
|
|
145
155
|
"financial.reconciliation.title": "Conciliação",
|
|
146
156
|
"financial.reconciliation.subtitle": "Concilie as linhas importadas do banco com seus lançamentos",
|
|
147
157
|
"financial.reconciliation.pending": "{{count}} para conciliar",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
-- 000_plg_rename.sql — rename legacy financial tables to plg_financial_* for pools
|
|
2
|
+
-- provisioned before the industry-pool rename. Guarded: fires only when the legacy
|
|
3
|
+
-- name exists and the target does not, so fresh pools skip every branch.
|
|
4
|
+
DO $$
|
|
5
|
+
BEGIN
|
|
6
|
+
IF to_regclass('public.financial_movements') IS NOT NULL AND to_regclass('public.plg_financial_movements') IS NULL THEN
|
|
7
|
+
ALTER TABLE public.financial_movements RENAME TO plg_financial_movements;
|
|
8
|
+
END IF;
|
|
9
|
+
IF to_regclass('public.chart_of_accounts') IS NOT NULL AND to_regclass('public.plg_financial_chart_of_accounts') IS NULL THEN
|
|
10
|
+
ALTER TABLE public.chart_of_accounts RENAME TO plg_financial_chart_of_accounts;
|
|
11
|
+
END IF;
|
|
12
|
+
IF to_regclass('public.bank_accounts') IS NOT NULL AND to_regclass('public.plg_financial_bank_accounts') IS NULL THEN
|
|
13
|
+
ALTER TABLE public.bank_accounts RENAME TO plg_financial_bank_accounts;
|
|
14
|
+
END IF;
|
|
15
|
+
IF to_regclass('public.card_brands') IS NOT NULL AND to_regclass('public.plg_financial_card_brands') IS NULL THEN
|
|
16
|
+
ALTER TABLE public.card_brands RENAME TO plg_financial_card_brands;
|
|
17
|
+
END IF;
|
|
18
|
+
IF to_regclass('public.cost_centers') IS NOT NULL AND to_regclass('public.plg_financial_cost_centers') IS NULL THEN
|
|
19
|
+
ALTER TABLE public.cost_centers RENAME TO plg_financial_cost_centers;
|
|
20
|
+
END IF;
|
|
21
|
+
IF to_regclass('public.cash_register_sessions') IS NOT NULL AND to_regclass('public.plg_financial_cash_register_sessions') IS NULL THEN
|
|
22
|
+
ALTER TABLE public.cash_register_sessions RENAME TO plg_financial_cash_register_sessions;
|
|
23
|
+
END IF;
|
|
24
|
+
IF to_regclass('public.payment_methods') IS NOT NULL AND to_regclass('public.plg_financial_payment_methods') IS NULL THEN
|
|
25
|
+
ALTER TABLE public.payment_methods RENAME TO plg_financial_payment_methods;
|
|
26
|
+
END IF;
|
|
27
|
+
IF to_regclass('public.payment_method_types') IS NOT NULL AND to_regclass('public.plg_financial_payment_method_types') IS NULL THEN
|
|
28
|
+
ALTER TABLE public.payment_method_types RENAME TO plg_financial_payment_method_types;
|
|
29
|
+
END IF;
|
|
30
|
+
END $$;
|