@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
package/src/data/mock.ts CHANGED
@@ -3,10 +3,10 @@ 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,
9
+ PaginatedResult, FinancialSummary, StatementEntry, StatementResult, CashSessionSummary,
10
10
  DateRange, InvoiceStatus, MovementStatus,
11
11
  } from '../types'
12
12
 
@@ -27,6 +27,10 @@ function today(): string {
27
27
  return new Date().toISOString().slice(0, 10)
28
28
  }
29
29
 
30
+ function round2(n: number): number {
31
+ return Math.round(n * 100) / 100
32
+ }
33
+
30
34
  function matchesDateRange(dateStr: string | undefined, range?: DateRange): boolean {
31
35
  if (!range || !dateStr) return true
32
36
  return dateStr >= range.from && dateStr <= range.to
@@ -281,43 +285,72 @@ export function createMockFinancialProvider(options?: MockFinancialProviderOptio
281
285
  },
282
286
 
283
287
  async payMovement(input: PayMovementInput): Promise<FinancialMovement> {
284
- const movement = store.movements.find((m) => m.id === input.movementId)
285
- if (!movement) throw new Error(`Movement ${input.movementId} not found`)
286
-
287
- movement.paidAmount += input.amount
288
- movement.paymentDate = input.paymentDate
289
- movement.paymentMethodId = input.paymentMethodId ?? movement.paymentMethodId
290
- movement.bankAccountId = input.bankAccountId ?? movement.bankAccountId
291
- movement.cashSessionId = input.cashSessionId ?? movement.cashSessionId
292
- movement.cardBrand = input.cardBrand
293
- movement.cardInstallments = input.cardInstallments
294
- movement.status = movement.paidAmount >= movement.amount ? 'paid' : 'partial'
295
- movement.updatedAt = now()
288
+ const bill = store.movements.find((m) => m.id === input.movementId)
289
+ if (!bill) throw new Error(`Movement ${input.movementId} not found`)
296
290
 
297
- // Update bank account balance
298
- if (movement.bankAccountId) {
299
- const account = store.bankAccounts.find((a) => a.id === movement.bankAccountId)
291
+ // Net card settlement: derive the processing/MDR fee from the payment method (credits only).
292
+ const method = input.paymentMethodId ? store.paymentMethods.find((pm) => pm.id === input.paymentMethodId) : undefined
293
+ const feePct = bill.direction === 'credit' ? (method?.discountValue ?? 0) : 0
294
+ const thisFee = round2(input.amount * (feePct / 100))
295
+
296
+ // 1. Record the cash event as its OWN payment movement (one row per payment).
297
+ const payment: FinancialMovement = {
298
+ id: uid(),
299
+ invoiceId: bill.invoiceId,
300
+ direction: bill.direction,
301
+ movementKind: 'payment',
302
+ amount: input.amount,
303
+ paidAmount: input.amount,
304
+ feeAmount: thisFee,
305
+ status: 'paid',
306
+ dueDate: bill.dueDate,
307
+ paymentDate: input.paymentDate,
308
+ installmentNumber: bill.installmentNumber,
309
+ paymentMethodId: input.paymentMethodId,
310
+ paymentMethodTypeId: input.paymentMethodTypeId,
311
+ bankAccountId: input.bankAccountId,
312
+ cashSessionId: input.cashSessionId,
313
+ cardBrand: input.cardBrand,
314
+ cardInstallments: input.cardInstallments,
315
+ notes: input.notes,
316
+ tenantId,
317
+ createdAt: now(),
318
+ updatedAt: now(),
319
+ }
320
+ store.movements.push(payment)
321
+
322
+ // 2. Update the bill (obligation) cache: cumulative paid + status. Clear its cash
323
+ // fields so it stops being a cash event (the payment row is now the cash event).
324
+ bill.paidAmount = round2(bill.paidAmount + input.amount)
325
+ bill.status = bill.paidAmount >= bill.amount ? 'paid' : 'partial'
326
+ bill.paymentDate = undefined
327
+ bill.bankAccountId = undefined
328
+ bill.updatedAt = now()
329
+
330
+ // Update bank account balance by the NET cash impact (credit nets the fee).
331
+ if (input.bankAccountId) {
332
+ const account = store.bankAccounts.find((a) => a.id === input.bankAccountId)
300
333
  if (account) {
301
- if (movement.direction === 'credit') {
302
- account.currentBalance += input.amount
334
+ if (bill.direction === 'credit') {
335
+ account.currentBalance = round2(account.currentBalance + (input.amount - thisFee))
303
336
  } else {
304
- account.currentBalance -= input.amount
337
+ account.currentBalance = round2(account.currentBalance - input.amount)
305
338
  }
306
339
  account.updatedAt = now()
307
340
  }
308
341
  }
309
342
 
310
- // Recalculate invoice status
311
- if (movement.invoiceId) {
312
- const invoice = store.invoices.find((i) => i.id === movement.invoiceId)
343
+ // 3. Recalculate invoice status from its bills
344
+ if (bill.invoiceId) {
345
+ const invoice = store.invoices.find((i) => i.id === bill.invoiceId)
313
346
  if (invoice) {
314
- const invoiceMovements = store.movements.filter((m) => m.invoiceId === invoice.id && m.movementKind === 'bill')
315
- invoice.paidAmount = invoiceMovements.reduce((sum, m) => sum + m.paidAmount, 0)
347
+ const bills = store.movements.filter((m) => m.invoiceId === invoice.id && m.movementKind === 'bill')
348
+ invoice.paidAmount = bills.reduce((sum, m) => sum + m.paidAmount, 0)
316
349
  recalcInvoiceStatus(invoice)
317
350
  }
318
351
  }
319
352
 
320
- return movement
353
+ return payment
321
354
  },
322
355
 
323
356
  async cancelMovement(id: string): Promise<void> {
@@ -327,6 +360,44 @@ export function createMockFinancialProvider(options?: MockFinancialProviderOptio
327
360
  movement.updatedAt = now()
328
361
  },
329
362
 
363
+ // --- Transfers (between accounts) ---
364
+ async createTransfer(input: CreateTransferInput): Promise<TransferResult> {
365
+ if (!input.fromAccountId || !input.toAccountId || input.fromAccountId === input.toAccountId) {
366
+ throw new Error('Source and destination accounts must differ')
367
+ }
368
+ if (!(input.amount > 0)) throw new Error('Amount must be greater than zero')
369
+ const transferId = uid()
370
+ const debitMovementId = uid()
371
+ const creditMovementId = uid()
372
+ const base = {
373
+ movementKind: 'transfer' as const,
374
+ amount: input.amount,
375
+ paidAmount: input.amount,
376
+ feeAmount: 0,
377
+ status: 'paid' as const,
378
+ dueDate: input.date,
379
+ paymentDate: input.date,
380
+ notes: input.notes,
381
+ tenantId,
382
+ createdAt: now(),
383
+ updatedAt: now(),
384
+ }
385
+ store.movements.push({
386
+ ...base, id: debitMovementId, direction: 'debit', bankAccountId: input.fromAccountId,
387
+ metadata: { transferId, transferRole: 'out', counterAccountId: input.toAccountId },
388
+ } as FinancialMovement)
389
+ store.movements.push({
390
+ ...base, id: creditMovementId, direction: 'credit', bankAccountId: input.toAccountId,
391
+ metadata: { transferId, transferRole: 'in', counterAccountId: input.fromAccountId },
392
+ } as FinancialMovement)
393
+ // Reflect in stored balances.
394
+ const from = store.bankAccounts.find((a) => a.id === input.fromAccountId)
395
+ const to = store.bankAccounts.find((a) => a.id === input.toAccountId)
396
+ if (from) { from.currentBalance = round2(from.currentBalance - input.amount); from.updatedAt = now() }
397
+ if (to) { to.currentBalance = round2(to.currentBalance + input.amount); to.updatedAt = now() }
398
+ return { transferId, debitMovementId, creditMovementId }
399
+ },
400
+
330
401
  // --- Bank Accounts ---
331
402
  async getBankAccounts(): Promise<BankAccount[]> {
332
403
  return store.bankAccounts.filter((a) => a.isActive)
@@ -447,35 +518,99 @@ export function createMockFinancialProvider(options?: MockFinancialProviderOptio
447
518
  return results.sort((a, b) => a.expectedDate.localeCompare(b.expectedDate))
448
519
  },
449
520
 
450
- // --- Statements ---
451
- async getStatement(query: StatementQuery): Promise<StatementEntry[]> {
452
- const account = store.bankAccounts.find((a) => a.id === query.bankAccountId)
453
- if (!account) return []
454
-
455
- const movements = store.movements
456
- .filter((m) =>
457
- m.bankAccountId === query.bankAccountId &&
458
- m.status === 'paid' &&
459
- matchesDateRange(m.paymentDate, query.dateRange)
460
- )
461
- .sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''))
462
-
463
- let runningBalance = account.initialBalance
464
- const entries: StatementEntry[] = []
521
+ // --- Statements (ERP extract: opening balance + realized cash + per-row net/fee) ---
522
+ async getStatement(query: StatementQuery): Promise<StatementResult> {
523
+ const todayStr = today()
524
+ // A cash event = any non-cancelled movement with a payment_date (payment, transfer,
525
+ // or a legacy in-place-paid bill). Bills lose their payment_date once split.
526
+ const isRealized = (m: FinancialMovement) => m.status !== 'cancelled' && !!m.paymentDate
527
+ const consolidated = !query.bankAccountId
528
+ const scopeAccounts = consolidated
529
+ ? store.bankAccounts.filter((a) => a.isActive)
530
+ : store.bankAccounts.filter((a) => a.id === query.bankAccountId)
531
+ const accountIds = new Set(scopeAccounts.map((a) => a.id))
532
+ const accountNameById = new Map(store.bankAccounts.map((a) => [a.id, a.name]))
533
+ const initialSum = scopeAccounts.reduce((s, a) => s + a.initialBalance, 0)
534
+ // Only bail when a specific account was asked for and not found.
535
+ if (!consolidated && accountIds.size === 0) {
536
+ return { entries: [], openingBalance: 0, closingBalance: 0, totalCredits: 0, totalDebits: 0, totalFees: 0, net: 0, accountId: query.bankAccountId }
537
+ }
465
538
 
466
- for (const movement of movements) {
467
- if (movement.direction === 'credit') {
468
- runningBalance += movement.paidAmount
469
- } else {
470
- runningBalance -= movement.paidAmount
539
+ const netOf = (m: FinancialMovement) => m.direction === 'credit'
540
+ ? m.paidAmount - (m.feeAmount ?? 0)
541
+ : -m.paidAmount
542
+
543
+ // Consolidated => all realized movements (incl. those with no bank account).
544
+ const inScope = store.movements.filter((m) =>
545
+ isRealized(m) && (consolidated || (m.bankAccountId && accountIds.has(m.bankAccountId))))
546
+
547
+ const openingBalance = round2(initialSum + inScope
548
+ .filter((m) => (m.paymentDate ?? '') < query.dateRange.from)
549
+ .reduce((s, m) => s + netOf(m), 0))
550
+
551
+ const rangeMovs = inScope
552
+ .filter((m) => matchesDateRange(m.paymentDate, query.dateRange))
553
+ .sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? '') || a.createdAt.localeCompare(b.createdAt))
554
+
555
+ let balance = openingBalance
556
+ let totalCredits = 0, totalDebits = 0, totalFees = 0
557
+ const entries: StatementEntry[] = rangeMovs.map((movement) => {
558
+ const gross = movement.paidAmount
559
+ const isCredit = movement.direction === 'credit'
560
+ const fee = isCredit ? (movement.feeAmount ?? 0) : 0
561
+ const net = isCredit ? round2(gross - fee) : gross
562
+ const isTransfer = movement.movementKind === 'transfer'
563
+ const meta = (movement.metadata ?? {}) as any
564
+ const entryKind: StatementEntry['entryKind'] = isTransfer
565
+ ? (meta.transferRole === 'in' ? 'transfer-in' : 'transfer-out')
566
+ : 'movement'
567
+ const counterAccountId = isTransfer ? meta.counterAccountId : undefined
568
+ balance = round2(isCredit ? balance + net : balance - net)
569
+ if (!(consolidated && isTransfer)) {
570
+ if (isCredit) totalCredits = round2(totalCredits + net)
571
+ else totalDebits = round2(totalDebits + net)
572
+ totalFees = round2(totalFees + fee)
573
+ }
574
+ return {
575
+ movement,
576
+ invoice: movement.invoiceId ? store.invoices.find((i) => i.id === movement.invoiceId) : undefined,
577
+ runningBalance: balance,
578
+ gross, fee, net, entryKind,
579
+ counterAccountId,
580
+ counterAccountName: counterAccountId ? accountNameById.get(counterAccountId) : undefined,
581
+ } as StatementEntry
582
+ })
583
+
584
+ // Link reconciled bank lines to the internal movement they were matched to.
585
+ for (const e of entries) {
586
+ const mid = e.movement.matchedMovementId
587
+ const mm = mid ? store.movements.find((m) => m.id === mid) : undefined
588
+ if (!mm) continue
589
+ const inv = mm.invoiceId ? store.invoices.find((i) => i.id === mm.invoiceId) : undefined
590
+ e.reconciledWith = {
591
+ movementId: mm.id,
592
+ description: mm.notes ?? undefined,
593
+ invoiceId: mm.invoiceId,
594
+ fiscalNumber: inv?.fiscalNumber,
595
+ contactName: inv?.contactName,
471
596
  }
472
- const invoice = movement.invoiceId
473
- ? store.invoices.find((i) => i.id === movement.invoiceId)
474
- : undefined
475
- entries.push({ movement, invoice, runningBalance })
476
597
  }
477
598
 
478
- return entries
599
+ const accountCurrentBalance = round2(initialSum + inScope
600
+ .filter((m) => (m.paymentDate ?? '') <= todayStr)
601
+ .reduce((s, m) => s + netOf(m), 0))
602
+
603
+ return {
604
+ entries,
605
+ openingBalance,
606
+ closingBalance: balance,
607
+ totalCredits,
608
+ totalDebits,
609
+ totalFees,
610
+ net: round2(totalCredits - totalDebits),
611
+ accountId: query.bankAccountId,
612
+ accountCurrentBalance,
613
+ }
479
614
  },
480
615
 
481
616
  // --- Summary ---
@@ -496,7 +631,7 @@ export function createMockFinancialProvider(options?: MockFinancialProviderOptio
496
631
  const monthEnd = todayStr
497
632
 
498
633
  const monthMovements = store.movements.filter((m) =>
499
- m.status === 'paid' && matchesDateRange(m.paymentDate, dateRange ?? { from: monthStart, to: monthEnd })
634
+ m.movementKind === 'payment' && matchesDateRange(m.paymentDate, dateRange ?? { from: monthStart, to: monthEnd })
500
635
  )
501
636
 
502
637
  const overdueReceivable = pendingReceivable.filter((m) => m.dueDate < todayStr)
@@ -514,6 +649,84 @@ export function createMockFinancialProvider(options?: MockFinancialProviderOptio
514
649
  overduePayableAmount: overduePayable.reduce((s, m) => s + (m.amount - m.paidAmount), 0),
515
650
  }
516
651
  },
652
+
653
+ // --- Reconciliation (conciliação) ---
654
+ async importBankTransactions(input) {
655
+ let imported = 0, duplicates = 0
656
+ for (const line of input.lines) {
657
+ const dup = store.movements.some((m) => m.externalSource === line.externalSource && m.externalId === line.externalId)
658
+ if (dup) { duplicates++; continue }
659
+ store.movements.push({
660
+ id: uid(),
661
+ direction: line.direction,
662
+ movementKind: 'payment',
663
+ amount: line.amount,
664
+ paidAmount: line.amount,
665
+ status: 'paid',
666
+ dueDate: line.date,
667
+ paymentDate: line.date,
668
+ bankAccountId: line.bankAccountId ?? input.bankAccountId,
669
+ notes: line.description,
670
+ externalId: line.externalId,
671
+ externalSource: line.externalSource,
672
+ tenantId,
673
+ createdAt: now(),
674
+ updatedAt: now(),
675
+ } as FinancialMovement)
676
+ imported++
677
+ }
678
+ return { imported, duplicates }
679
+ },
680
+
681
+ async getUnreconciled(query) {
682
+ let results = store.movements.filter((m) => m.externalSource && !m.reconciledAt)
683
+ if (query?.bankAccountId) results = results.filter((m) => m.bankAccountId === query.bankAccountId)
684
+ if (query?.dateRange) results = results.filter((m) => matchesDateRange(m.paymentDate, query.dateRange))
685
+ return results.sort((a, b) => (b.paymentDate ?? '').localeCompare(a.paymentDate ?? ''))
686
+ },
687
+
688
+ async suggestReconciliation(bankMovementId) {
689
+ const line = store.movements.find((m) => m.id === bankMovementId)
690
+ if (!line) return []
691
+ const amount = line.amount
692
+ const tol = Math.max(0.01, round2(amount * 0.02))
693
+ const lineDate = line.paymentDate ?? line.dueDate
694
+ return store.movements
695
+ .filter((m) => !m.externalSource && !m.reconciledAt && m.direction === line.direction && m.id !== bankMovementId && Math.abs(m.amount - amount) <= tol)
696
+ .map((m) => {
697
+ const amtScore = amount > 0 ? Math.max(0, 1 - Math.abs(m.amount - amount) / amount) : 0
698
+ const refDate = m.dueDate ?? m.paymentDate
699
+ const days = lineDate && refDate ? Math.abs((new Date(lineDate).getTime() - new Date(refDate).getTime()) / 86400000) : 30
700
+ const dateScore = Math.max(0, 1 - days / 30)
701
+ return {
702
+ movement: m,
703
+ invoice: m.invoiceId ? store.invoices.find((i) => i.id === m.invoiceId) : undefined,
704
+ score: round2(0.7 * amtScore + 0.3 * dateScore),
705
+ }
706
+ })
707
+ .sort((a, b) => b.score - a.score)
708
+ .slice(0, 25)
709
+ },
710
+
711
+ async reconcileMovement(input) {
712
+ const line = store.movements.find((m) => m.id === input.bankMovementId)
713
+ if (line) { line.reconciledAt = now(); line.matchedMovementId = input.matchedMovementId; line.updatedAt = now() }
714
+ if (input.matchedMovementId) {
715
+ const internal = store.movements.find((m) => m.id === input.matchedMovementId)
716
+ if (internal) { internal.reconciledAt = now(); internal.updatedAt = now() }
717
+ }
718
+ },
719
+
720
+ async unreconcileMovement(bankMovementId) {
721
+ const line = store.movements.find((m) => m.id === bankMovementId)
722
+ if (!line) return
723
+ const matchedId = line.matchedMovementId
724
+ line.reconciledAt = undefined; line.matchedMovementId = undefined; line.updatedAt = now()
725
+ if (matchedId) {
726
+ const internal = store.movements.find((m) => m.id === matchedId)
727
+ if (internal) { internal.reconciledAt = undefined; internal.updatedAt = now() }
728
+ }
729
+ },
517
730
  }
518
731
 
519
732
  return provider