@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
package/src/data/supabase.ts
CHANGED
|
@@ -3,11 +3,11 @@ import type {
|
|
|
3
3
|
Invoice, InvoiceItem, FinancialMovement, BankAccount,
|
|
4
4
|
CashSession, PaymentMethod, PaymentMethodType, ChartOfAccountsNode,
|
|
5
5
|
CardTransaction,
|
|
6
|
-
CreateInvoiceInput, PayMovementInput,
|
|
6
|
+
CreateInvoiceInput, PayMovementInput, CreateTransferInput, TransferResult,
|
|
7
7
|
OpenCashSessionInput, CloseCashSessionInput, CreateBankAccountInput,
|
|
8
8
|
InvoiceQuery, MovementQuery, StatementQuery,
|
|
9
|
-
PaginatedResult, FinancialSummary, StatementEntry, CashSessionSummary,
|
|
10
|
-
DateRange,
|
|
9
|
+
PaginatedResult, FinancialSummary, StatementEntry, StatementResult, CashSessionSummary,
|
|
10
|
+
DateRange, ReconciliationCandidate,
|
|
11
11
|
} from '../types'
|
|
12
12
|
import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
|
|
13
13
|
import { getFinancialTenantId } from '../lib/tenant'
|
|
@@ -95,7 +95,7 @@ async function sweepOverdue() {
|
|
|
95
95
|
// S2 (data-model refactor): paid amount, balance and status are DERIVED from the
|
|
96
96
|
// append-only ledger via v_invoice_balances — the single source of truth — not
|
|
97
97
|
// from stored order.stage / metadata.paidAmount (which drift). See
|
|
98
|
-
// docs/
|
|
98
|
+
// docs/data-model-refactor.md.
|
|
99
99
|
async function fetchInvoiceBalances(
|
|
100
100
|
pub: any,
|
|
101
101
|
ids: string[],
|
|
@@ -112,6 +112,25 @@ async function fetchInvoiceBalances(
|
|
|
112
112
|
return map
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
function round2(n: number): number {
|
|
116
|
+
return Math.round(n * 100) / 100
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Card/MDR fee percent for a configured payment method (0 when none / not a fee method). */
|
|
120
|
+
async function getMethodFeePct(pub: any, paymentMethodId?: string): Promise<number> {
|
|
121
|
+
if (!paymentMethodId) return 0
|
|
122
|
+
const { data } = await pub.from('payment_methods').select('discount_value').eq('id', paymentMethodId).single()
|
|
123
|
+
return Number(data?.discount_value) || 0
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Generate a correlation id; falls back when crypto.randomUUID is unavailable. */
|
|
127
|
+
function genId(): string {
|
|
128
|
+
try {
|
|
129
|
+
if (typeof crypto !== 'undefined' && (crypto as any).randomUUID) return (crypto as any).randomUUID()
|
|
130
|
+
} catch { /* noop */ }
|
|
131
|
+
return 'tr-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10)
|
|
132
|
+
}
|
|
133
|
+
|
|
115
134
|
export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
116
135
|
const provider: FinancialDataProvider = {
|
|
117
136
|
// --- Invoices (orders at stage=invoiced/paid/partial/overdue/cancelled with direction) ---
|
|
@@ -309,18 +328,54 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
309
328
|
const { core, pub } = getClients()
|
|
310
329
|
const { data: mov } = await pub.from('financial_movements').select('*').eq('id', input.movementId).single()
|
|
311
330
|
if (!mov) throw new Error('Movement not found')
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
331
|
+
const tenantId = mov.tenant_id ?? getTenantId()
|
|
332
|
+
|
|
333
|
+
// Net card settlement: derive the processing/MDR fee (inflows only in v1).
|
|
334
|
+
const feePct = mov.direction === 'credit' ? await getMethodFeePct(pub, input.paymentMethodId) : 0
|
|
335
|
+
const thisFee = round2(input.amount * (feePct / 100))
|
|
336
|
+
|
|
337
|
+
// 1. Record the cash event as its OWN payment movement — NOT by mutating the
|
|
338
|
+
// bill. Two payments on one installment → two distinct rows in the extract.
|
|
339
|
+
const paymentRow: any = {
|
|
340
|
+
tenant_id: tenantId,
|
|
341
|
+
invoice_id: mov.invoice_id,
|
|
342
|
+
direction: mov.direction,
|
|
343
|
+
movement_kind: 'payment',
|
|
344
|
+
amount: input.amount,
|
|
345
|
+
paid_amount: input.amount,
|
|
346
|
+
fee_amount: thisFee,
|
|
347
|
+
status: 'paid',
|
|
348
|
+
due_date: mov.due_date,
|
|
349
|
+
payment_date: input.paymentDate,
|
|
350
|
+
installment_number: mov.installment_number,
|
|
351
|
+
payment_method_id: input.paymentMethodId ?? null,
|
|
352
|
+
payment_method_type_id: input.paymentMethodTypeId ?? null,
|
|
353
|
+
bank_account_id: input.bankAccountId ?? null,
|
|
354
|
+
cash_session_id: input.cashSessionId ?? null,
|
|
355
|
+
card_brand: input.cardBrand ?? null,
|
|
356
|
+
card_installments: input.cardInstallments ?? null,
|
|
357
|
+
notes: input.notes ?? null,
|
|
358
|
+
}
|
|
359
|
+
let { data, error } = await pub.from('financial_movements').insert(paymentRow).select().single()
|
|
360
|
+
// Graceful fallback if the fee_amount migration hasn't been applied yet.
|
|
361
|
+
if (error && /fee_amount/i.test(error.message ?? '')) {
|
|
362
|
+
const { fee_amount, ...rest } = paymentRow
|
|
363
|
+
;({ data, error } = await pub.from('financial_movements').insert(rest).select().single())
|
|
364
|
+
}
|
|
365
|
+
if (error) throw error
|
|
366
|
+
|
|
367
|
+
// 2. Update the bill (obligation) cache: cumulative paid + status. It stays a
|
|
368
|
+
// pure obligation (no bank/method/date) — the cash lives on the payment row.
|
|
369
|
+
// v_invoice_balances reads bill.paid_amount, so this keeps balances correct.
|
|
370
|
+
const newPaid = round2((mov.paid_amount ?? 0) + input.amount)
|
|
371
|
+
const billStatus = newPaid >= mov.amount ? 'paid' : 'partial'
|
|
372
|
+
// Clear the bill's cash fields so it stops being a cash event (the payment row is now
|
|
373
|
+
// the cash event) — prevents double-counting legacy in-place-paid bills.
|
|
374
|
+
await pub.from('financial_movements')
|
|
375
|
+
.update({ paid_amount: newPaid, status: billStatus, payment_date: null, bank_account_id: null })
|
|
376
|
+
.eq('id', mov.id)
|
|
377
|
+
|
|
378
|
+
// 3. Update invoice (order) status based on its bills
|
|
324
379
|
if (mov.invoice_id) {
|
|
325
380
|
const { data: allMovs } = await pub.from('financial_movements')
|
|
326
381
|
.select('status, amount, paid_amount')
|
|
@@ -350,6 +405,53 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
350
405
|
await pub.from('financial_movements').update({ status: 'cancelled' }).eq('id', id)
|
|
351
406
|
},
|
|
352
407
|
|
|
408
|
+
// --- Transfers (between accounts) ---
|
|
409
|
+
async createTransfer(input: CreateTransferInput): Promise<TransferResult> {
|
|
410
|
+
const { pub } = getClients()
|
|
411
|
+
if (!input.fromAccountId || !input.toAccountId || input.fromAccountId === input.toAccountId) {
|
|
412
|
+
throw new Error('Source and destination accounts must differ')
|
|
413
|
+
}
|
|
414
|
+
if (!(input.amount > 0)) throw new Error('Amount must be greater than zero')
|
|
415
|
+
const tenantId = getTenantId()
|
|
416
|
+
const transferId = genId()
|
|
417
|
+
const base = {
|
|
418
|
+
tenant_id: tenantId,
|
|
419
|
+
movement_kind: 'transfer' as const,
|
|
420
|
+
amount: input.amount,
|
|
421
|
+
paid_amount: input.amount,
|
|
422
|
+
fee_amount: 0,
|
|
423
|
+
status: 'paid' as const,
|
|
424
|
+
due_date: input.date,
|
|
425
|
+
payment_date: input.date,
|
|
426
|
+
notes: input.notes ?? null,
|
|
427
|
+
}
|
|
428
|
+
const rowsToInsert = [
|
|
429
|
+
{
|
|
430
|
+
...base,
|
|
431
|
+
direction: 'debit',
|
|
432
|
+
bank_account_id: input.fromAccountId,
|
|
433
|
+
metadata: { transferId, transferRole: 'out', counterAccountId: input.toAccountId },
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
...base,
|
|
437
|
+
direction: 'credit',
|
|
438
|
+
bank_account_id: input.toAccountId,
|
|
439
|
+
metadata: { transferId, transferRole: 'in', counterAccountId: input.fromAccountId },
|
|
440
|
+
},
|
|
441
|
+
]
|
|
442
|
+
let { data, error } = await pub.from('financial_movements').insert(rowsToInsert).select('id, direction')
|
|
443
|
+
// Graceful fallback if the fee_amount migration hasn't been applied yet.
|
|
444
|
+
if (error && /fee_amount/i.test(error.message ?? '')) {
|
|
445
|
+
const stripped = rowsToInsert.map(({ fee_amount, ...rest }) => rest)
|
|
446
|
+
;({ data, error } = await pub.from('financial_movements').insert(stripped).select('id, direction'))
|
|
447
|
+
}
|
|
448
|
+
if (error) throw error
|
|
449
|
+
const rows = (data ?? []) as Array<{ id: string; direction: string }>
|
|
450
|
+
const debitMovementId = rows.find((r) => r.direction === 'debit')?.id ?? ''
|
|
451
|
+
const creditMovementId = rows.find((r) => r.direction === 'credit')?.id ?? ''
|
|
452
|
+
return { transferId, debitMovementId, creditMovementId }
|
|
453
|
+
},
|
|
454
|
+
|
|
353
455
|
// --- Bank Accounts ---
|
|
354
456
|
async getBankAccounts(): Promise<BankAccount[]> {
|
|
355
457
|
const { core, pub } = getClients()
|
|
@@ -451,23 +553,142 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
451
553
|
return [] // TODO: implement when card_transactions table exists
|
|
452
554
|
},
|
|
453
555
|
|
|
454
|
-
// --- Statements ---
|
|
455
|
-
async getStatement(query: StatementQuery): Promise<
|
|
556
|
+
// --- Statements (ERP extract: opening balance + realized cash + per-row net/fee) ---
|
|
557
|
+
async getStatement(query: StatementQuery): Promise<StatementResult> {
|
|
456
558
|
const { core, pub } = getClients()
|
|
457
|
-
const
|
|
458
|
-
|
|
559
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
560
|
+
// The extract = actual cash events. A cash event is any non-cancelled movement with
|
|
561
|
+
// a payment_date: payment movements, transfers, and legacy in-place-paid bills (which
|
|
562
|
+
// still carry a payment_date until the split migration backfills them). Once a payment
|
|
563
|
+
// movement exists for a bill, payMovement clears the bill's payment_date, so a single
|
|
564
|
+
// cash amount is represented by exactly one row — no double counting, no migration needed.
|
|
565
|
+
|
|
566
|
+
// 1. Resolve in-scope accounts. No bankAccountId => consolidated: ALL realized cash
|
|
567
|
+
// (including movements with no bank_account_id, e.g. cash/card payments not tied to an account).
|
|
568
|
+
const { data: acctRows } = await pub.from('bank_accounts').select('id, name, initial_balance, is_active')
|
|
569
|
+
const allAccounts = (acctRows ?? []) as any[]
|
|
570
|
+
const consolidated = !query.bankAccountId
|
|
571
|
+
const scopeAccounts = consolidated
|
|
572
|
+
? allAccounts.filter((a) => a.is_active)
|
|
573
|
+
: allAccounts.filter((a) => a.id === query.bankAccountId)
|
|
574
|
+
const accountIds = scopeAccounts.map((a) => a.id)
|
|
575
|
+
const accountNameById = new Map<string, string>(allAccounts.map((a) => [a.id, a.name]))
|
|
576
|
+
const initialSum = scopeAccounts.reduce((s, a) => s + (Number(a.initial_balance) || 0), 0)
|
|
577
|
+
// Only bail when a specific account was asked for and not found.
|
|
578
|
+
if (!consolidated && accountIds.length === 0) {
|
|
579
|
+
return { entries: [], openingBalance: 0, closingBalance: 0, totalCredits: 0, totalDebits: 0, totalFees: 0, net: 0, accountId: query.bankAccountId }
|
|
580
|
+
}
|
|
581
|
+
// Consolidated => no account filter (catches NULL-account movements too).
|
|
582
|
+
const scopeFilter = (qb: any) => consolidated ? qb : qb.in('bank_account_id', accountIds)
|
|
583
|
+
|
|
584
|
+
const netOf = (r: any) => r.direction === 'credit'
|
|
585
|
+
? (Number(r.paid_amount) || 0) - (Number(r.fee_amount) || 0)
|
|
586
|
+
: -(Number(r.paid_amount) || 0)
|
|
587
|
+
|
|
588
|
+
// 2. Opening balance = Σ initial_balance + prior net (payment_date < from).
|
|
589
|
+
// Select '*' so this still works before the fee_amount migration lands (fee → 0).
|
|
590
|
+
const { data: priorRows } = await scopeFilter(pub.from('financial_movements')
|
|
591
|
+
.select('*')
|
|
592
|
+
.neq('status', 'cancelled')
|
|
593
|
+
.lt('payment_date', query.dateRange.from))
|
|
594
|
+
const priorNet = (priorRows ?? []).reduce((s: number, r: any) => s + netOf(r), 0)
|
|
595
|
+
const openingBalance = round2(initialSum + priorNet)
|
|
596
|
+
|
|
597
|
+
// 3. In-range realized rows.
|
|
598
|
+
const { data: movs } = await scopeFilter(pub.from('financial_movements')
|
|
599
|
+
.select('*')
|
|
600
|
+
.neq('status', 'cancelled')
|
|
459
601
|
.gte('payment_date', query.dateRange.from).lte('payment_date', query.dateRange.to)
|
|
460
|
-
.order('payment_date')
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
602
|
+
.order('payment_date').order('created_at'))
|
|
603
|
+
|
|
604
|
+
// 4. Join invoices for descriptions.
|
|
605
|
+
const invoiceIds = [...new Set((movs ?? []).map((r: any) => r.invoice_id).filter(Boolean))]
|
|
606
|
+
const invoiceById = new Map<string, Invoice>()
|
|
607
|
+
if (invoiceIds.length) {
|
|
608
|
+
const { data: orders } = await core.from('orders').select('id, party_id, reference_number, metadata').in('id', invoiceIds)
|
|
609
|
+
for (const o of (orders ?? []) as any[]) {
|
|
610
|
+
invoiceById.set(o.id, { id: o.id, contactId: o.party_id, contactName: (o.metadata as any)?.contactName, fiscalNumber: o.reference_number } as Invoice)
|
|
611
|
+
}
|
|
612
|
+
}
|
|
464
613
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
614
|
+
// 5. Build entries with running NET balance.
|
|
615
|
+
let balance = openingBalance
|
|
616
|
+
let totalCredits = 0, totalDebits = 0, totalFees = 0
|
|
617
|
+
const entries: StatementEntry[] = (movs ?? []).map((r: any) => {
|
|
618
|
+
const movement = snakeToCamel(r) as unknown as FinancialMovement
|
|
619
|
+
const gross = Number(r.paid_amount) || 0
|
|
620
|
+
const isCredit = r.direction === 'credit'
|
|
621
|
+
const fee = isCredit ? (Number(r.fee_amount) || 0) : 0
|
|
622
|
+
const net = isCredit ? round2(gross - fee) : gross
|
|
623
|
+
const meta = (r.metadata ?? {}) as any
|
|
624
|
+
const isTransfer = r.movement_kind === 'transfer'
|
|
625
|
+
const entryKind: StatementEntry['entryKind'] = isTransfer
|
|
626
|
+
? (meta.transferRole === 'in' ? 'transfer-in' : 'transfer-out')
|
|
627
|
+
: 'movement'
|
|
628
|
+
const counterAccountId = isTransfer ? meta.counterAccountId : undefined
|
|
629
|
+
if (isCredit) balance = round2(balance + net)
|
|
630
|
+
else balance = round2(balance - net)
|
|
631
|
+
// Exclude internal transfers from consolidated totals (they net to zero across accounts).
|
|
632
|
+
if (!(consolidated && isTransfer)) {
|
|
633
|
+
if (isCredit) totalCredits = round2(totalCredits + net)
|
|
634
|
+
else totalDebits = round2(totalDebits + net)
|
|
635
|
+
totalFees = round2(totalFees + fee)
|
|
636
|
+
}
|
|
637
|
+
return {
|
|
638
|
+
movement,
|
|
639
|
+
invoice: r.invoice_id ? invoiceById.get(r.invoice_id) : undefined,
|
|
640
|
+
runningBalance: balance,
|
|
641
|
+
gross, fee, net, entryKind,
|
|
642
|
+
counterAccountId,
|
|
643
|
+
counterAccountName: counterAccountId ? accountNameById.get(counterAccountId) : undefined,
|
|
644
|
+
}
|
|
470
645
|
})
|
|
646
|
+
|
|
647
|
+
// 5b. Resolve the reconciliation counterpart for matched bank lines, so the
|
|
648
|
+
// extract row can link to the internal transaction it was conciliated with.
|
|
649
|
+
const matchedIds = [...new Set(entries.map((e) => e.movement.matchedMovementId).filter(Boolean))] as string[]
|
|
650
|
+
if (matchedIds.length) {
|
|
651
|
+
const { data: matched } = await pub.from('financial_movements').select('id, invoice_id, notes').in('id', matchedIds)
|
|
652
|
+
const matchedById = new Map<string, any>((matched ?? []).map((m: any) => [m.id, m]))
|
|
653
|
+
const matchedInvoiceIds = [...new Set((matched ?? []).map((m: any) => m.invoice_id).filter(Boolean))] as string[]
|
|
654
|
+
const invById = new Map<string, any>()
|
|
655
|
+
if (matchedInvoiceIds.length) {
|
|
656
|
+
const { data: orders } = await core.from('orders').select('id, reference_number, metadata').in('id', matchedInvoiceIds)
|
|
657
|
+
for (const o of (orders ?? []) as any[]) invById.set(o.id, o)
|
|
658
|
+
}
|
|
659
|
+
for (const e of entries) {
|
|
660
|
+
const mid = e.movement.matchedMovementId
|
|
661
|
+
const mm = mid ? matchedById.get(mid) : undefined
|
|
662
|
+
if (!mm) continue
|
|
663
|
+
const inv = mm.invoice_id ? invById.get(mm.invoice_id) : undefined
|
|
664
|
+
e.reconciledWith = {
|
|
665
|
+
movementId: mm.id,
|
|
666
|
+
description: mm.notes ?? undefined,
|
|
667
|
+
invoiceId: mm.invoice_id ?? undefined,
|
|
668
|
+
fiscalNumber: inv?.reference_number ?? undefined,
|
|
669
|
+
contactName: (inv?.metadata as any)?.contactName ?? undefined,
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// 6. Account current balance (ledger as-of-today).
|
|
675
|
+
const { data: todayRows } = await scopeFilter(pub.from('financial_movements')
|
|
676
|
+
.select('*')
|
|
677
|
+
.neq('status', 'cancelled')
|
|
678
|
+
.lte('payment_date', today))
|
|
679
|
+
const accountCurrentBalance = round2(initialSum + (todayRows ?? []).reduce((s: number, r: any) => s + netOf(r), 0))
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
entries,
|
|
683
|
+
openingBalance,
|
|
684
|
+
closingBalance: balance,
|
|
685
|
+
totalCredits,
|
|
686
|
+
totalDebits,
|
|
687
|
+
totalFees,
|
|
688
|
+
net: round2(totalCredits - totalDebits),
|
|
689
|
+
accountId: query.bankAccountId,
|
|
690
|
+
accountCurrentBalance,
|
|
691
|
+
}
|
|
471
692
|
},
|
|
472
693
|
|
|
473
694
|
// --- Summary ---
|
|
@@ -489,7 +710,8 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
489
710
|
const overdueDebit = unpaidDebit.filter((m: any) => m.status === 'overdue' || m.due_date < today)
|
|
490
711
|
|
|
491
712
|
const monthStart = today.slice(0, 7) + '-01'
|
|
492
|
-
|
|
713
|
+
// Monthly cash flow = payment cash events (one row per payment), never bills.
|
|
714
|
+
const monthPaid = allMovs.filter((m: any) => m.movement_kind === 'payment' && m.payment_date >= monthStart)
|
|
493
715
|
|
|
494
716
|
return {
|
|
495
717
|
totalBalance,
|
|
@@ -503,6 +725,113 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
|
503
725
|
overduePayableAmount: overdueDebit.reduce((s: number, m: any) => s + m.amount - m.paid_amount, 0),
|
|
504
726
|
}
|
|
505
727
|
},
|
|
728
|
+
|
|
729
|
+
// --- Reconciliation (conciliação) ---
|
|
730
|
+
// Imported bank-statement lines live in financial_movements tagged with
|
|
731
|
+
// (external_source, external_id), status='paid'. Reconciling links a line
|
|
732
|
+
// to the internal movement it settles (or accepts it standalone).
|
|
733
|
+
async importBankTransactions(input) {
|
|
734
|
+
const { pub } = getClients()
|
|
735
|
+
const tenantId = getTenantId()
|
|
736
|
+
let imported = 0, duplicates = 0
|
|
737
|
+
for (const line of input.lines) {
|
|
738
|
+
const { error } = await pub.from('financial_movements').insert({
|
|
739
|
+
tenant_id: tenantId,
|
|
740
|
+
direction: line.direction,
|
|
741
|
+
movement_kind: 'payment',
|
|
742
|
+
amount: line.amount,
|
|
743
|
+
paid_amount: line.amount,
|
|
744
|
+
status: 'paid',
|
|
745
|
+
due_date: line.date,
|
|
746
|
+
payment_date: line.date,
|
|
747
|
+
bank_account_id: line.bankAccountId ?? input.bankAccountId ?? null,
|
|
748
|
+
notes: line.description,
|
|
749
|
+
external_id: line.externalId,
|
|
750
|
+
external_source: line.externalSource,
|
|
751
|
+
})
|
|
752
|
+
if (error) {
|
|
753
|
+
// 23505 = unique violation on uq_financial_movements_external → already imported.
|
|
754
|
+
if ((error as any).code === '23505') duplicates++
|
|
755
|
+
else throw error
|
|
756
|
+
} else imported++
|
|
757
|
+
}
|
|
758
|
+
return { imported, duplicates }
|
|
759
|
+
},
|
|
760
|
+
|
|
761
|
+
async getUnreconciled(query) {
|
|
762
|
+
const { pub } = getClients()
|
|
763
|
+
let qb = pub.from('financial_movements').select('*')
|
|
764
|
+
.not('external_source', 'is', null)
|
|
765
|
+
.is('reconciled_at', null)
|
|
766
|
+
if (query?.bankAccountId) qb = qb.eq('bank_account_id', query.bankAccountId)
|
|
767
|
+
if (query?.dateRange) qb = qb.gte('payment_date', query.dateRange.from).lte('payment_date', query.dateRange.to)
|
|
768
|
+
const { data } = await qb.order('payment_date', { ascending: false })
|
|
769
|
+
return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as FinancialMovement)
|
|
770
|
+
},
|
|
771
|
+
|
|
772
|
+
async suggestReconciliation(bankMovementId) {
|
|
773
|
+
const { core, pub } = getClients()
|
|
774
|
+
const { data: line } = await pub.from('financial_movements').select('*').eq('id', bankMovementId).single()
|
|
775
|
+
if (!line) return []
|
|
776
|
+
const amount = Number(line.amount) || 0
|
|
777
|
+
const lineDate = line.payment_date ?? line.due_date
|
|
778
|
+
// Candidates: app-native (non-imported), same direction, not reconciled, amount within ±2%.
|
|
779
|
+
const tol = Math.max(0.01, round2(amount * 0.02))
|
|
780
|
+
const { data } = await pub.from('financial_movements').select('*')
|
|
781
|
+
.is('external_source', null)
|
|
782
|
+
.is('reconciled_at', null)
|
|
783
|
+
.eq('direction', line.direction)
|
|
784
|
+
.gte('amount', round2(amount - tol))
|
|
785
|
+
.lte('amount', round2(amount + tol))
|
|
786
|
+
.neq('id', bankMovementId)
|
|
787
|
+
.limit(25)
|
|
788
|
+
const candidates = ((data ?? []) as any[]).map((r) => {
|
|
789
|
+
const amtDiff = Math.abs((Number(r.amount) || 0) - amount)
|
|
790
|
+
const amtScore = amount > 0 ? Math.max(0, 1 - amtDiff / amount) : 0
|
|
791
|
+
const refDate = r.due_date ?? r.payment_date
|
|
792
|
+
const days = lineDate && refDate
|
|
793
|
+
? Math.abs((new Date(lineDate).getTime() - new Date(refDate).getTime()) / 86400000)
|
|
794
|
+
: 30
|
|
795
|
+
const dateScore = Math.max(0, 1 - days / 30)
|
|
796
|
+
return {
|
|
797
|
+
movement: snakeToCamel(r) as unknown as FinancialMovement,
|
|
798
|
+
score: round2(0.7 * amtScore + 0.3 * dateScore),
|
|
799
|
+
} as ReconciliationCandidate
|
|
800
|
+
}).sort((a, b) => b.score - a.score)
|
|
801
|
+
// Attach invoice contact names for display.
|
|
802
|
+
const invoiceIds = [...new Set(candidates.map((c) => c.movement.invoiceId).filter(Boolean))] as string[]
|
|
803
|
+
if (invoiceIds.length) {
|
|
804
|
+
const { data: orders } = await core.from('orders').select('id, reference_number, metadata').in('id', invoiceIds)
|
|
805
|
+
const byId = new Map<string, any>((orders ?? []).map((o: any) => [o.id, o]))
|
|
806
|
+
for (const c of candidates) {
|
|
807
|
+
const o = c.movement.invoiceId ? byId.get(c.movement.invoiceId) : undefined
|
|
808
|
+
if (o) c.invoice = { id: o.id, contactName: (o.metadata as any)?.contactName, fiscalNumber: o.reference_number } as Invoice
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
return candidates
|
|
812
|
+
},
|
|
813
|
+
|
|
814
|
+
async reconcileMovement(input) {
|
|
815
|
+
const { pub } = getClients()
|
|
816
|
+
const now = new Date().toISOString()
|
|
817
|
+
await pub.from('financial_movements').update({
|
|
818
|
+
reconciled_at: now,
|
|
819
|
+
matched_movement_id: input.matchedMovementId ?? null,
|
|
820
|
+
}).eq('id', input.bankMovementId)
|
|
821
|
+
if (input.matchedMovementId) {
|
|
822
|
+
// The internal movement is now settled+reconciled by the bank line.
|
|
823
|
+
await pub.from('financial_movements').update({ reconciled_at: now }).eq('id', input.matchedMovementId)
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
|
|
827
|
+
async unreconcileMovement(bankMovementId) {
|
|
828
|
+
const { pub } = getClients()
|
|
829
|
+
const { data: line } = await pub.from('financial_movements').select('matched_movement_id').eq('id', bankMovementId).single()
|
|
830
|
+
await pub.from('financial_movements').update({ reconciled_at: null, matched_movement_id: null }).eq('id', bankMovementId)
|
|
831
|
+
if (line?.matched_movement_id) {
|
|
832
|
+
await pub.from('financial_movements').update({ reconciled_at: null }).eq('id', line.matched_movement_id)
|
|
833
|
+
}
|
|
834
|
+
},
|
|
506
835
|
}
|
|
507
836
|
|
|
508
837
|
return provider
|
package/src/data/types.ts
CHANGED
|
@@ -2,11 +2,13 @@ import type {
|
|
|
2
2
|
Invoice, InvoiceItem, FinancialMovement, BankAccount,
|
|
3
3
|
CashSession, PaymentMethod, PaymentMethodType, ChartOfAccountsNode,
|
|
4
4
|
CardTransaction,
|
|
5
|
-
CreateInvoiceInput, PayMovementInput,
|
|
5
|
+
CreateInvoiceInput, PayMovementInput, CreateTransferInput, TransferResult,
|
|
6
6
|
OpenCashSessionInput, CloseCashSessionInput, CreateBankAccountInput,
|
|
7
7
|
InvoiceQuery, MovementQuery, StatementQuery,
|
|
8
|
-
PaginatedResult, FinancialSummary,
|
|
8
|
+
PaginatedResult, FinancialSummary, StatementResult, CashSessionSummary,
|
|
9
9
|
DateRange,
|
|
10
|
+
ImportBankTransactionsInput, ImportBankTransactionsResult,
|
|
11
|
+
UnreconciledQuery, ReconciliationCandidate, ReconcileInput,
|
|
10
12
|
} from '../types'
|
|
11
13
|
|
|
12
14
|
export interface FinancialDataProvider {
|
|
@@ -23,6 +25,9 @@ export interface FinancialDataProvider {
|
|
|
23
25
|
payMovement(input: PayMovementInput): Promise<FinancialMovement>
|
|
24
26
|
cancelMovement(id: string): Promise<void>
|
|
25
27
|
|
|
28
|
+
// --- Transfers (between accounts) ---
|
|
29
|
+
createTransfer(input: CreateTransferInput): Promise<TransferResult>
|
|
30
|
+
|
|
26
31
|
// --- Bank Accounts ---
|
|
27
32
|
getBankAccounts(): Promise<BankAccount[]>
|
|
28
33
|
createBankAccount(data: CreateBankAccountInput): Promise<BankAccount>
|
|
@@ -46,8 +51,22 @@ export interface FinancialDataProvider {
|
|
|
46
51
|
getCardTransactions(dateRange?: DateRange): Promise<CardTransaction[]>
|
|
47
52
|
|
|
48
53
|
// --- Statements ---
|
|
49
|
-
getStatement(query: StatementQuery): Promise<
|
|
54
|
+
getStatement(query: StatementQuery): Promise<StatementResult>
|
|
50
55
|
|
|
51
56
|
// --- Summary / Dashboard ---
|
|
52
57
|
getSummary(dateRange?: DateRange): Promise<FinancialSummary>
|
|
58
|
+
|
|
59
|
+
// --- Reconciliation (conciliação) ---
|
|
60
|
+
// Optional: implemented by providers that back the matching UI. A bank
|
|
61
|
+
// connector imports lines; the SDK ReconciliationView reads/matches them.
|
|
62
|
+
/** Upsert normalized bank-statement lines as movements (idempotent by external id). */
|
|
63
|
+
importBankTransactions?(input: ImportBankTransactionsInput): Promise<ImportBankTransactionsResult>
|
|
64
|
+
/** Imported lines not yet reconciled (externalSource set, reconciledAt null). */
|
|
65
|
+
getUnreconciled?(query?: UnreconciledQuery): Promise<FinancialMovement[]>
|
|
66
|
+
/** Internal-movement candidates to match a given imported line, ranked by score. */
|
|
67
|
+
suggestReconciliation?(bankMovementId: string): Promise<ReconciliationCandidate[]>
|
|
68
|
+
/** Reconcile: link to an internal movement, or accept the line standalone. */
|
|
69
|
+
reconcileMovement?(input: ReconcileInput): Promise<void>
|
|
70
|
+
/** Undo a reconciliation. */
|
|
71
|
+
unreconcileMovement?(bankMovementId: string): Promise<void>
|
|
53
72
|
}
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,8 @@ export function createSafeFinancialProvider(): FinancialDataProvider {
|
|
|
24
24
|
import { financialRegistries } from './registries'
|
|
25
25
|
import { financialLocales } from './locales'
|
|
26
26
|
import { FinancialGeneralSettings } from './components/FinancialGeneralSettings'
|
|
27
|
+
import { PersonStatementWidget } from './components/PersonStatementWidget'
|
|
28
|
+
import { PluginSettingsPanel } from '@fayz-ai/saas'
|
|
27
29
|
|
|
28
30
|
// ---------------------------------------------------------------------------
|
|
29
31
|
// Public types
|
|
@@ -64,6 +66,8 @@ export interface FinancialPluginOptions {
|
|
|
64
66
|
statements?: boolean
|
|
65
67
|
commissions?: boolean
|
|
66
68
|
cards?: boolean
|
|
69
|
+
/** Bank reconciliation (conciliação). Defaults off — enable with a bank connector. */
|
|
70
|
+
reconciliation?: boolean
|
|
67
71
|
}
|
|
68
72
|
labels?: Partial<FinancialPluginLabels>
|
|
69
73
|
currency?: {
|
|
@@ -155,6 +159,8 @@ function resolveConfig(options?: FinancialPluginOptions): ResolvedFinancialConfi
|
|
|
155
159
|
statements: options?.modules?.statements !== false,
|
|
156
160
|
commissions: options?.modules?.commissions !== false,
|
|
157
161
|
cards: options?.modules?.cards !== false,
|
|
162
|
+
// Opt-in: only shown when an app explicitly enables reconciliation (bank connector present).
|
|
163
|
+
reconciliation: options?.modules?.reconciliation === true,
|
|
158
164
|
},
|
|
159
165
|
labels: { ...DEFAULT_LABELS, ...options?.labels },
|
|
160
166
|
currency: { ...DEFAULT_CURRENCY, ...options?.currency },
|
|
@@ -183,6 +189,11 @@ export function createFinancialPlugin(options?: FinancialPluginOptions): PluginM
|
|
|
183
189
|
const store = createFinancialStore(provider)
|
|
184
190
|
const dashboardWidgets = createFinancialDashboardWidgets({ config, provider, store })
|
|
185
191
|
|
|
192
|
+
// Per-person statement tab (account_central-style): bound to this plugin's provider.
|
|
193
|
+
const PersonStatementBound: React.FC<any> = (props) =>
|
|
194
|
+
React.createElement(PersonStatementWidget, { person: props.item, provider, config })
|
|
195
|
+
PersonStatementBound.displayName = 'FinancialPersonStatement'
|
|
196
|
+
|
|
186
197
|
const PageComponent: React.FC<any> = () =>
|
|
187
198
|
React.createElement(FinancialPage, { config, provider, store, registries: financialRegistries })
|
|
188
199
|
|
|
@@ -218,7 +229,15 @@ export function createFinancialPlugin(options?: FinancialPluginOptions): PluginM
|
|
|
218
229
|
permission: { feature: 'financial', action: 'read' as const },
|
|
219
230
|
},
|
|
220
231
|
],
|
|
221
|
-
widgets: [
|
|
232
|
+
widgets: [
|
|
233
|
+
{
|
|
234
|
+
id: 'financial-person-statement',
|
|
235
|
+
zone: 'person.detail.financial',
|
|
236
|
+
component: PersonStatementBound as unknown as React.ComponentType<unknown>,
|
|
237
|
+
order: 0,
|
|
238
|
+
permission: { feature: 'financial', action: 'read' as const },
|
|
239
|
+
},
|
|
240
|
+
],
|
|
222
241
|
dashboardWidgets,
|
|
223
242
|
aiTools: [
|
|
224
243
|
{
|
|
@@ -290,7 +309,14 @@ export function createFinancialPlugin(options?: FinancialPluginOptions): PluginM
|
|
|
290
309
|
icon: 'DollarSign',
|
|
291
310
|
component: (() => {
|
|
292
311
|
const FinancialSettingsTab: React.FC = () =>
|
|
293
|
-
React.createElement(
|
|
312
|
+
React.createElement(PluginSettingsPanel, {
|
|
313
|
+
title: 'Financial Settings',
|
|
314
|
+
subtitle: 'Payment methods, accounts, and configurations',
|
|
315
|
+
generalSettings: React.createElement(FinancialGeneralSettings),
|
|
316
|
+
registries: financialRegistries,
|
|
317
|
+
hostPluginId: 'financial',
|
|
318
|
+
routeBase: '/settings/financial',
|
|
319
|
+
})
|
|
294
320
|
FinancialSettingsTab.displayName = 'FinancialSettingsTab'
|
|
295
321
|
return FinancialSettingsTab as unknown as React.ComponentType<unknown>
|
|
296
322
|
})(),
|