@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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/dist/FinancialContext.d.ts +2 -0
  3. package/dist/FinancialContext.d.ts.map +1 -1
  4. package/dist/FinancialPage.d.ts.map +1 -1
  5. package/dist/components/PaymentModal.d.ts.map +1 -1
  6. package/dist/components/PersonStatementWidget.d.ts +18 -0
  7. package/dist/components/PersonStatementWidget.d.ts.map +1 -0
  8. package/dist/components/TransferModal.d.ts +7 -0
  9. package/dist/components/TransferModal.d.ts.map +1 -0
  10. package/dist/contract.test.d.ts +2 -0
  11. package/dist/contract.test.d.ts.map +1 -0
  12. package/dist/data/capability.test.d.ts +2 -0
  13. package/dist/data/capability.test.d.ts.map +1 -0
  14. package/dist/data/mock.d.ts.map +1 -1
  15. package/dist/data/supabase.d.ts.map +1 -1
  16. package/dist/data/types.d.ts +13 -2
  17. package/dist/data/types.d.ts.map +1 -1
  18. package/dist/index.cjs +1529 -263
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.ts +2 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1437 -171
  23. package/dist/index.js.map +1 -1
  24. package/dist/locales/en.d.ts.map +1 -1
  25. package/dist/locales/pt-BR.d.ts.map +1 -1
  26. package/dist/registries.d.ts.map +1 -1
  27. package/dist/store.d.ts +4 -2
  28. package/dist/store.d.ts.map +1 -1
  29. package/dist/types.d.ts +111 -1
  30. package/dist/types.d.ts.map +1 -1
  31. package/dist/views/InvoiceDetailView.d.ts +3 -1
  32. package/dist/views/InvoiceDetailView.d.ts.map +1 -1
  33. package/dist/views/InvoiceFormView.d.ts.map +1 -1
  34. package/dist/views/PayablesView.d.ts +5 -1
  35. package/dist/views/PayablesView.d.ts.map +1 -1
  36. package/dist/views/ReceivablesView.d.ts +5 -1
  37. package/dist/views/ReceivablesView.d.ts.map +1 -1
  38. package/dist/views/ReconciliationView.d.ts +3 -0
  39. package/dist/views/ReconciliationView.d.ts.map +1 -0
  40. package/dist/views/StatementsView.d.ts +3 -1
  41. package/dist/views/StatementsView.d.ts.map +1 -1
  42. package/dist/views/navReferrer.d.ts +5 -0
  43. package/dist/views/navReferrer.d.ts.map +1 -0
  44. package/package.json +8 -5
  45. package/src/FinancialContext.tsx +2 -0
  46. package/src/FinancialPage.tsx +32 -17
  47. package/src/components/PaymentModal.tsx +58 -11
  48. package/src/components/PersonStatementWidget.tsx +133 -0
  49. package/src/components/TransferModal.tsx +150 -0
  50. package/src/contract.test.ts +58 -0
  51. package/src/data/capability.test.ts +90 -0
  52. package/src/data/mock.ts +265 -52
  53. package/src/data/supabase.ts +359 -30
  54. package/src/data/types.ts +22 -3
  55. package/src/index.ts +28 -2
  56. package/src/locales/en.ts +76 -0
  57. package/src/locales/pt-BR.ts +76 -0
  58. package/src/migrations/006_extract_fee_amount.sql +20 -0
  59. package/src/migrations/006_rls_policies.sql +37 -0
  60. package/src/migrations/007_reconciliation.sql +32 -0
  61. package/src/migrations/008_split_payment_movements.sql +29 -0
  62. package/src/registries.ts +3 -1
  63. package/src/store.ts +27 -7
  64. package/src/types.ts +126 -1
  65. package/src/views/InvoiceDetailView.tsx +70 -51
  66. package/src/views/InvoiceFormView.tsx +55 -20
  67. package/src/views/PayablesView.tsx +13 -2
  68. package/src/views/ReceivablesView.tsx +13 -2
  69. package/src/views/ReconciliationView.tsx +211 -0
  70. package/src/views/StatementsView.tsx +241 -29
  71. package/src/views/navReferrer.ts +24 -0
@@ -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/dogfood-sprint/DATA-MODEL-REFACTOR.md.
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 newPaid = (mov.paid_amount ?? 0) + input.amount
313
- const newStatus = newPaid >= mov.amount ? 'paid' : 'partial'
314
- const updates: any = { paid_amount: newPaid, status: newStatus, payment_date: input.paymentDate }
315
- if (input.paymentMethodId) updates.payment_method_id = input.paymentMethodId
316
- if (input.paymentMethodTypeId) updates.payment_method_type_id = input.paymentMethodTypeId
317
- if (input.bankAccountId) updates.bank_account_id = input.bankAccountId
318
- if (input.cashSessionId) updates.cash_session_id = input.cashSessionId
319
- if (input.cardBrand) updates.card_brand = input.cardBrand
320
- if (input.cardInstallments) updates.card_installments = input.cardInstallments
321
- const { data } = await pub.from('financial_movements').update(updates).eq('id', input.movementId).select().single()
322
-
323
- // Update invoice (order) status based on all its movements
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<StatementEntry[]> {
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 { data: movs } = await pub.from('financial_movements').select('*')
458
- .eq('bank_account_id', query.bankAccountId).eq('status', 'paid')
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
- const { data: account } = await pub.from('bank_accounts').select('initial_balance').eq('id', query.bankAccountId).single()
463
- let balance = account?.initial_balance ?? 0
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
- return (movs ?? []).map((r: any) => {
466
- const mov = snakeToCamel(r) as unknown as FinancialMovement
467
- if (mov.direction === 'credit') balance += mov.paidAmount
468
- else balance -= mov.paidAmount
469
- return { movement: mov, runningBalance: balance }
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
- const monthPaid = allMovs.filter((m: any) => m.status === 'paid' && m.payment_date >= monthStart)
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, StatementEntry, CashSessionSummary,
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<StatementEntry[]>
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(FinancialGeneralSettings)
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
  })(),