@fayz-ai/plugin-financial 0.9.0 → 0.9.2

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 (95) hide show
  1. package/README.md +3 -3
  2. package/dist/components/PersonStatementWidget.d.ts +1 -1
  3. package/dist/components/PersonStatementWidget.d.ts.map +1 -1
  4. package/dist/{FinancialContext.d.ts → context.d.ts} +4 -2
  5. package/dist/context.d.ts.map +1 -0
  6. package/dist/data/registries.d.ts.map +1 -0
  7. package/dist/index.d.ts +13 -4
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1426 -1426
  10. package/dist/index.js.map +1 -1
  11. package/dist/integrations/stripe/connectorDef.d.ts +14 -0
  12. package/dist/integrations/stripe/connectorDef.d.ts.map +1 -0
  13. package/dist/integrations/stripe/data.d.ts +43 -0
  14. package/dist/integrations/stripe/data.d.ts.map +1 -0
  15. package/dist/lib/message-events.d.ts +12 -0
  16. package/dist/lib/message-events.d.ts.map +1 -0
  17. package/dist/{quick-add.d.ts → lib/quick-add.d.ts} +1 -1
  18. package/dist/lib/quick-add.d.ts.map +1 -0
  19. package/dist/locales/en.d.ts.map +1 -1
  20. package/dist/locales/pt-BR.d.ts.map +1 -1
  21. package/dist/{FinancialPage.d.ts → views/FinancialPage.d.ts} +3 -3
  22. package/dist/views/FinancialPage.d.ts.map +1 -0
  23. package/dist/views/InvoiceDetailView.d.ts.map +1 -1
  24. package/dist/views/InvoiceFormView.d.ts.map +1 -1
  25. package/dist/views/InvoiceListView.d.ts.map +1 -1
  26. package/dist/views/PayablesView.d.ts +1 -1
  27. package/dist/views/PayablesView.d.ts.map +1 -1
  28. package/dist/views/ReceivablesView.d.ts +1 -1
  29. package/dist/views/ReceivablesView.d.ts.map +1 -1
  30. package/dist/views/StatementsView.d.ts.map +1 -1
  31. package/dist/views/dashboardWidgets.d.ts +1 -1
  32. package/dist/views/dashboardWidgets.d.ts.map +1 -1
  33. package/package.json +12 -14
  34. package/dist/FinancialContext.d.ts.map +0 -1
  35. package/dist/FinancialPage.d.ts.map +0 -1
  36. package/dist/contract.test.d.ts +0 -2
  37. package/dist/contract.test.d.ts.map +0 -1
  38. package/dist/quick-add.d.ts.map +0 -1
  39. package/dist/registries.d.ts.map +0 -1
  40. package/src/FinancialContext.tsx +0 -59
  41. package/src/FinancialPage.tsx +0 -413
  42. package/src/README.md +0 -155
  43. package/src/components/FinancialGeneralSettings.tsx +0 -39
  44. package/src/components/FinancialOnboarding.tsx +0 -87
  45. package/src/components/FinancialSettings.tsx +0 -31
  46. package/src/components/PaymentModal.tsx +0 -369
  47. package/src/components/PersonStatementWidget.tsx +0 -133
  48. package/src/components/TransferModal.tsx +0 -150
  49. package/src/contract.test.ts +0 -58
  50. package/src/data/capability.test.ts +0 -135
  51. package/src/data/index.ts +0 -3
  52. package/src/data/mock.ts +0 -842
  53. package/src/data/supabase.ts +0 -968
  54. package/src/data/tables.ts +0 -13
  55. package/src/data/types.ts +0 -86
  56. package/src/index.ts +0 -487
  57. package/src/lib/tenant.ts +0 -3
  58. package/src/locales/en.ts +0 -493
  59. package/src/locales/index.ts +0 -7
  60. package/src/locales/pt-BR.ts +0 -493
  61. package/src/migrations/000_plg_rename.sql +0 -30
  62. package/src/migrations/001_financial_base.sql +0 -109
  63. package/src/migrations/002_chart_of_accounts.sql +0 -28
  64. package/src/migrations/003_card_brands.sql +0 -12
  65. package/src/migrations/004_order_to_cash.sql +0 -178
  66. package/src/migrations/005_seed_defaults.sql +0 -38
  67. package/src/migrations/006_rls_policies.sql +0 -37
  68. package/src/migrations/006b_extract_fee_amount.sql +0 -20
  69. package/src/migrations/007_reconciliation.sql +0 -32
  70. package/src/migrations/007b_movement_payment_method_type.sql +0 -6
  71. package/src/migrations/008_split_payment_movements.sql +0 -29
  72. package/src/migrations/009_agent_rpcs.sql +0 -165
  73. package/src/migrations/010_invoice_internal.sql +0 -133
  74. package/src/migrations/011_nfe.sql +0 -58
  75. package/src/migrations/index.ts +0 -923
  76. package/src/quick-add.ts +0 -58
  77. package/src/registries.ts +0 -308
  78. package/src/store.ts +0 -329
  79. package/src/types.ts +0 -592
  80. package/src/views/CardsView.tsx +0 -213
  81. package/src/views/CashRegistersView.tsx +0 -597
  82. package/src/views/CommissionsView.tsx +0 -18
  83. package/src/views/InvoiceDetailView.tsx +0 -616
  84. package/src/views/InvoiceFormView.tsx +0 -945
  85. package/src/views/InvoiceListView.tsx +0 -323
  86. package/src/views/NotasFiscaisView.tsx +0 -209
  87. package/src/views/PayablesView.tsx +0 -48
  88. package/src/views/QuickTransactionForm.tsx +0 -548
  89. package/src/views/ReceivablesView.tsx +0 -48
  90. package/src/views/ReconciliationView.tsx +0 -211
  91. package/src/views/StatementsView.tsx +0 -335
  92. package/src/views/SummaryView.tsx +0 -203
  93. package/src/views/dashboardWidgets.tsx +0 -437
  94. package/src/views/navReferrer.ts +0 -24
  95. /package/dist/{registries.d.ts → data/registries.d.ts} +0 -0
@@ -1,968 +0,0 @@
1
- import type { FinancialDataProvider } from './types'
2
- import type {
3
- Invoice, InvoiceItem, FinancialMovement, BankAccount,
4
- CashSession, PaymentMethod, PaymentMethodType, ChartOfAccountsNode,
5
- CostCenter, CardTransaction,
6
- CreateInvoiceInput, PayMovementInput, CreateTransferInput, TransferResult,
7
- OpenCashSessionInput, CloseCashSessionInput, CreateBankAccountInput,
8
- InvoiceQuery, MovementQuery, StatementQuery,
9
- PaginatedResult, FinancialSummary, StatementEntry, StatementResult, CashSessionSummary,
10
- DateRange, ReconciliationCandidate,
11
- NotaFiscal, CreateNotaFiscalInput, NotaFiscalQuery,
12
- } from '../types'
13
- import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
14
- import { getFinancialTenantId } from '../lib/tenant'
15
- import { T } from './tables'
16
-
17
- function getTenantId(): string | undefined {
18
- // Local override wins; else use the app's active tenant so writes pass RLS.
19
- return getFinancialTenantId() ?? getActiveTenantId()
20
- }
21
-
22
- function snakeToCamel(obj: Record<string, unknown>): Record<string, unknown> {
23
- const result: Record<string, unknown> = {}
24
- for (const [key, value] of Object.entries(obj)) {
25
- result[key.replace(/_([a-z])/g, (_, c) => c.toUpperCase())] = value
26
- }
27
- return result
28
- }
29
-
30
- function camelToSnake(obj: Record<string, unknown>): Record<string, unknown> {
31
- const result: Record<string, unknown> = {}
32
- for (const [key, value] of Object.entries(obj)) {
33
- if (key.startsWith('_')) continue
34
- result[key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`)] = value
35
- }
36
- return result
37
- }
38
-
39
- function getClients() {
40
- const supabase = getSupabaseClientOptional() as any
41
- if (!supabase) throw new Error('Supabase not initialized')
42
- // Core tables now live in PUBLIC (legacy dedicated core schema is gone). Both
43
- // handles are the single public client; `core` retained for call-site readability.
44
- return { core: supabase, pub: supabase }
45
- }
46
-
47
- /** Map order stage to InvoiceStatus for the financial UI */
48
- function mapStageToInvoiceStatus(stage?: string): string {
49
- switch (stage) {
50
- case 'paid': return 'paid'
51
- case 'partial': return 'partial'
52
- case 'overdue': return 'overdue'
53
- case 'cancelled': return 'cancelled'
54
- case 'invoiced': return 'open'
55
- case 'booked': return 'open'
56
- default: return 'open'
57
- }
58
- }
59
-
60
- let _overdueSwept = false
61
-
62
- /** Batch-mark overdue: pending/partial movements past due → 'overdue', then update parent invoices */
63
- async function sweepOverdue() {
64
- if (_overdueSwept) return
65
- _overdueSwept = true
66
- try {
67
- const { core, pub } = getClients()
68
- const today = new Date().toISOString().slice(0, 10)
69
-
70
- // Mark overdue movements
71
- const { data: overdueMovs } = await pub.from(T.movements)
72
- .update({ status: 'overdue' })
73
- .in('status', ['pending', 'partial'])
74
- .lt('due_date', today)
75
- .select('invoice_id')
76
-
77
- // Collect unique invoice IDs and update their status
78
- if (overdueMovs && overdueMovs.length > 0) {
79
- const invoiceIds = [...new Set(overdueMovs.map((m: any) => m.invoice_id).filter(Boolean))]
80
- for (const invId of invoiceIds) {
81
- const { data: allMovs } = await pub.from(T.movements)
82
- .select('status')
83
- .eq('invoice_id', invId)
84
- .eq('movement_kind', 'bill')
85
- const movs = allMovs ?? []
86
- const hasOverdue = movs.some((m: any) => m.status === 'overdue')
87
- const allPaid = movs.every((m: any) => m.status === 'paid')
88
- const invoiceStatus = allPaid ? 'paid' : hasOverdue ? 'overdue' : 'open'
89
- const invoiceStage = allPaid ? 'paid' : hasOverdue ? 'overdue' : 'invoiced'
90
- // Only update financial stage — don't overwrite agenda status
91
- await core.from('orders').update({ stage: invoiceStage }).eq('id', invId)
92
- }
93
- }
94
- } catch {
95
- // Non-critical — don't block the UI
96
- }
97
- }
98
-
99
- // S2 (data-model refactor): paid amount, balance and status are DERIVED from the
100
- // append-only ledger via v_invoice_balances — the single source of truth — not
101
- // from stored order.stage / metadata.paidAmount (which drift). See
102
- // docs/DATA-MODEL.md.
103
- async function fetchInvoiceBalances(
104
- pub: any,
105
- ids: string[],
106
- ): Promise<Map<string, { paid: number; balance: number; status: string }>> {
107
- const map = new Map<string, { paid: number; balance: number; status: string }>()
108
- if (!ids.length) return map
109
- const { data } = await pub
110
- .from('v_invoice_balances')
111
- .select('invoice_id, paid, balance, status')
112
- .in('invoice_id', ids)
113
- for (const r of (data ?? []) as any[]) {
114
- map.set(r.invoice_id, { paid: Number(r.paid) || 0, balance: Number(r.balance) || 0, status: r.status })
115
- }
116
- return map
117
- }
118
-
119
- function round2(n: number): number {
120
- return Math.round(n * 100) / 100
121
- }
122
-
123
- async function findOrderIdsByItemMetadata(
124
- core: any,
125
- filters: { accountId?: string; costCenterId?: string },
126
- ): Promise<string[] | undefined> {
127
- if (!filters.accountId && !filters.costCenterId) return undefined
128
-
129
- let qb = core.from('order_items').select('order_id')
130
- if (filters.accountId) qb = qb.filter('metadata->>accountId', 'eq', filters.accountId)
131
- if (filters.costCenterId) qb = qb.filter('metadata->>costCenterId', 'eq', filters.costCenterId)
132
-
133
- const { data } = await qb
134
- const ids: string[] = []
135
- for (const row of (data ?? []) as Array<{ order_id?: unknown }>) {
136
- if (typeof row.order_id === 'string' && row.order_id.length > 0) ids.push(row.order_id)
137
- }
138
- return Array.from(new Set(ids))
139
- }
140
-
141
- /** Card/MDR fee percent for a configured payment method (0 when none / not a fee method). */
142
- async function getMethodFeePct(pub: any, paymentMethodId?: string): Promise<number> {
143
- if (!paymentMethodId) return 0
144
- const { data } = await pub.from(T.paymentMethods).select('discount_value').eq('id', paymentMethodId).single()
145
- return Number(data?.discount_value) || 0
146
- }
147
-
148
- /** Generate a correlation id; falls back when crypto.randomUUID is unavailable. */
149
- function genId(): string {
150
- try {
151
- if (typeof crypto !== 'undefined' && (crypto as any).randomUUID) return (crypto as any).randomUUID()
152
- } catch { /* noop */ }
153
- return 'tr-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10)
154
- }
155
-
156
- export function createSupabaseFinancialProvider(): FinancialDataProvider {
157
- const provider: FinancialDataProvider = {
158
- // --- Invoices (orders at stage=invoiced/paid/partial/overdue/cancelled with direction) ---
159
- async getInvoices(query: InvoiceQuery): Promise<PaginatedResult<Invoice>> {
160
- const { core, pub } = getClients()
161
- let qb = core.from('orders').select('*', { count: 'exact' })
162
-
163
- // Show orders with financial relevance: booked (with amount) + invoiced stages
164
- qb = qb.in('stage', ['booked', 'invoiced', 'paid', 'partial', 'overdue', 'cancelled'])
165
- qb = qb.gt('total', 0)
166
-
167
- // Filter by direction (credit=receivable, debit=payable)
168
- if (query.direction) {
169
- qb = qb.eq('direction', query.direction)
170
- }
171
-
172
- if (query.status) {
173
- const statuses = Array.isArray(query.status) ? query.status : [query.status]
174
- qb = qb.in('status', statuses)
175
- }
176
- const itemScopedOrderIds = await findOrderIdsByItemMetadata(core, {
177
- accountId: query.accountId,
178
- costCenterId: query.costCenterId,
179
- })
180
- if (itemScopedOrderIds) {
181
- if (itemScopedOrderIds.length === 0) return { data: [], total: 0 }
182
- qb = qb.in('id', itemScopedOrderIds)
183
- }
184
- if (query.contactId) qb = qb.eq('party_id', query.contactId)
185
- if (query.search) qb = qb.ilike('notes', `%${query.search}%`)
186
- // `to` is a plain date from the UI filter — widen it to end-of-day so the
187
- // last day of the range isn't cut off at midnight.
188
- if (query.dateRange) {
189
- const to = query.dateRange.to.length === 10 ? `${query.dateRange.to}T23:59:59.999` : query.dateRange.to
190
- qb = qb.gte('created_at', query.dateRange.from).lte('created_at', to)
191
- }
192
- const page = query.page ?? 1
193
- const pageSize = query.pageSize ?? 50
194
- qb = qb.range((page - 1) * pageSize, page * pageSize - 1).order('created_at', { ascending: false })
195
- const { data, count } = await qb
196
-
197
- const bal = await fetchInvoiceBalances(pub, (data ?? []).map((r: any) => r.id))
198
- const invoices: Invoice[] = (data ?? []).map((r: any) => {
199
- const meta = r.metadata ?? {}
200
- // Resolve direction from stage model or legacy kind
201
- const dir = r.direction ?? (r.kind === 'invoice_payable' ? 'debit' : 'credit')
202
- const b = bal.get(r.id)
203
- return {
204
- id: r.id,
205
- direction: dir,
206
- invoiceDate: r.created_at?.slice(0, 10) ?? '',
207
- fiscalNumber: r.reference_number,
208
- totalAmount: r.total ?? 0,
209
- paidAmount: b?.paid ?? meta.paidAmount ?? 0,
210
- status: b?.status ?? mapStageToInvoiceStatus(r.stage),
211
- totalInstallments: meta.installmentCount ?? 1,
212
- contactId: r.party_id,
213
- contactName: meta.contactName,
214
- observations: r.notes,
215
- itemsSummary: meta.itemsSummary,
216
- tenantId: r.tenant_id,
217
- createdAt: r.created_at,
218
- updatedAt: r.updated_at,
219
- } as Invoice
220
- })
221
- return { data: invoices, total: count ?? 0 }
222
- },
223
-
224
- async getInvoiceById(id: string): Promise<Invoice | null> {
225
- const { core, pub } = getClients()
226
- const { data } = await core.from('orders').select('*').eq('id', id).single()
227
- if (!data) return null
228
- const o = snakeToCamel(data) as any
229
- const meta = data.metadata ?? {}
230
- // Paid amount + status are DERIVED from the ledger view (single source of truth).
231
- const balance = (await fetchInvoiceBalances(pub, [id])).get(id)
232
- const { data: movs } = await pub.from(T.movements)
233
- .select('paid_amount')
234
- .eq('invoice_id', id)
235
- .eq('movement_kind', 'bill')
236
- return {
237
- id: o.id,
238
- direction: o.direction ?? (o.kind === 'invoice_payable' ? 'debit' : 'credit'),
239
- invoiceDate: o.createdAt?.slice(0, 10) ?? '',
240
- fiscalNumber: o.referenceNumber,
241
- totalAmount: o.total ?? 0, paidAmount: balance?.paid ?? 0,
242
- status: balance?.status ?? mapStageToInvoiceStatus(o.stage),
243
- totalInstallments: (movs ?? []).length || 1, contactId: o.partyId,
244
- contactName: meta.contactName, itemsSummary: meta.itemsSummary,
245
- bookingStartsAt: o.startsAt ?? undefined,
246
- observations: o.notes,
247
- tenantId: o.tenantId, createdAt: o.createdAt, updatedAt: o.updatedAt,
248
- } as Invoice
249
- },
250
-
251
- async getInvoiceItems(invoiceId: string): Promise<InvoiceItem[]> {
252
- const { core, pub } = getClients()
253
- const { data } = await core.from('order_items').select('*').eq('order_id', invoiceId).order('sort_order')
254
- return (data ?? []).map((r: any) => {
255
- const i = snakeToCamel(r) as any
256
- return {
257
- id: i.id, invoiceId, itemKind: i.metadata?.itemKind ?? 'other',
258
- description: i.name ?? '', quantity: i.quantity ?? 1,
259
- unitPrice: i.unitPrice ?? 0, totalAmount: i.total ?? 0,
260
- discount: i.discount ?? 0, surcharge: 0,
261
- referenceId: i.productId ?? i.serviceId,
262
- accountId: i.metadata?.accountId,
263
- costCenterId: i.metadata?.costCenterId,
264
- createdAt: i.createdAt,
265
- } as InvoiceItem
266
- })
267
- },
268
-
269
- async createInvoice(input: CreateInvoiceInput): Promise<Invoice> {
270
- const { core, pub } = getClients()
271
- const tenantId = getTenantId()
272
- const kind = input.direction === 'debit' ? 'invoice_payable' : 'invoice_receivable'
273
- const totalAmount = input.items.reduce((sum, item) => sum + item.quantity * item.unitPrice - (item.discount ?? 0) + (item.surcharge ?? 0), 0)
274
- const itemsSummary = input.items.map((i: any) => i.description).filter(Boolean).join(', ')
275
-
276
- // Auto-generate reference number if not provided
277
- let referenceNumber = input.fiscalNumber
278
- if (!referenceNumber && tenantId) {
279
- const prefix = input.direction === 'credit' ? 'REC' : 'PAG'
280
- const { data: seq } = await core.rpc('next_sequence', { p_tenant_id: tenantId, p_kind: kind })
281
- if (seq) referenceNumber = `${prefix}-${String(seq).padStart(5, '0')}`
282
- }
283
-
284
- const { data: order } = await core.from('orders').insert({
285
- tenant_id: tenantId, kind, status: 'open',
286
- stage: 'invoiced',
287
- direction: input.direction,
288
- reference_number: referenceNumber,
289
- total: totalAmount, party_id: input.contactId,
290
- notes: input.observations,
291
- currency: 'BRL',
292
- metadata: { itemsSummary, contactName: input.contactName, installmentCount: input.installments.length || 1 },
293
- }).select().single()
294
-
295
- if (order && input.items.length > 0) {
296
- await core.from('order_items').insert(
297
- input.items.map((item, i) => ({
298
- order_id: order.id, name: item.description,
299
- quantity: item.quantity, unit_price: item.unitPrice,
300
- discount: item.discount ?? 0,
301
- total: item.quantity * item.unitPrice - (item.discount ?? 0) + (item.surcharge ?? 0),
302
- sort_order: i,
303
- metadata: {
304
- itemKind: item.itemKind,
305
- referenceId: item.referenceId,
306
- accountId: item.accountId,
307
- costCenterId: item.costCenterId,
308
- },
309
- }))
310
- )
311
- }
312
-
313
- // Create installment movements
314
- if (order && input.installments.length > 0) {
315
- await pub.from(T.movements).insert(
316
- input.installments.map((inst, i) => ({
317
- tenant_id: tenantId, invoice_id: order.id,
318
- direction: input.direction, movement_kind: inst.movementKind,
319
- amount: inst.amount, paid_amount: 0, status: 'pending',
320
- due_date: inst.dueDate, installment_number: inst.installmentNumber ?? i + 1,
321
- }))
322
- )
323
- }
324
-
325
- return {
326
- id: order!.id, direction: input.direction, invoiceDate: new Date().toISOString().slice(0, 10),
327
- fiscalNumber: referenceNumber,
328
- totalAmount, paidAmount: 0, status: 'open', totalInstallments: input.installments.length || 1,
329
- contactName: input.contactName, itemsSummary,
330
- tenantId: tenantId!, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
331
- } as Invoice
332
- },
333
-
334
- async updateInvoice(id: string, partial: Partial<Invoice>): Promise<Invoice> {
335
- const { core, pub } = getClients()
336
- const row: any = {}
337
- if (partial.observations) row.notes = partial.observations
338
- if (partial.totalAmount) row.total = partial.totalAmount
339
- if (partial.status) row.status = partial.status
340
- if (Object.keys(row).length > 0) await core.from('orders').update(row).eq('id', id)
341
- return (await provider.getInvoiceById(id))!
342
- },
343
-
344
- async cancelInvoice(id: string): Promise<void> {
345
- const { core, pub } = getClients()
346
- await core.from('orders').update({ status: 'cancelled' }).eq('id', id)
347
- await pub.from(T.movements).update({ status: 'cancelled' }).eq('invoice_id', id).eq('status', 'pending')
348
- },
349
-
350
- // --- Movements (public.financial_movements) ---
351
- async getMovements(query: MovementQuery): Promise<PaginatedResult<FinancialMovement>> {
352
- const { core, pub } = getClients()
353
- let qb = pub.from(T.movements).select('*', { count: 'exact' })
354
- if (query.direction) qb = qb.eq('direction', query.direction)
355
- if (query.status) {
356
- const statuses = Array.isArray(query.status) ? query.status : [query.status]
357
- qb = qb.in('status', statuses)
358
- }
359
- if (query.bankAccountId) qb = qb.eq('bank_account_id', query.bankAccountId)
360
- if (query.cashSessionId) qb = qb.eq('cash_session_id', query.cashSessionId)
361
- if (query.invoiceId) qb = qb.eq('invoice_id', query.invoiceId)
362
- if (query.dateRange) qb = qb.gte('due_date', query.dateRange.from).lte('due_date', query.dateRange.to)
363
- const page = query.page ?? 1
364
- const pageSize = query.pageSize ?? 50
365
- qb = qb.range((page - 1) * pageSize, page * pageSize - 1).order('due_date')
366
- const { data, count } = await qb
367
- return { data: (data ?? []).map((r: any) => snakeToCamel(r) as unknown as FinancialMovement), total: count ?? 0 }
368
- },
369
-
370
- async payMovement(input: PayMovementInput): Promise<FinancialMovement> {
371
- const { core, pub } = getClients()
372
- const { data: mov } = await pub.from(T.movements).select('*').eq('id', input.movementId).single()
373
- if (!mov) throw new Error('Movement not found')
374
- const tenantId = mov.tenant_id ?? getTenantId()
375
-
376
- // Net card settlement: derive the processing/MDR fee (inflows only in v1).
377
- const feePct = mov.direction === 'credit' ? await getMethodFeePct(pub, input.paymentMethodId) : 0
378
- const thisFee = round2(input.amount * (feePct / 100))
379
-
380
- // 1. Record the cash event as its OWN payment movement — NOT by mutating the
381
- // bill. Two payments on one installment → two distinct rows in the extract.
382
- const paymentRow: any = {
383
- tenant_id: tenantId,
384
- invoice_id: mov.invoice_id,
385
- direction: mov.direction,
386
- movement_kind: 'payment',
387
- amount: input.amount,
388
- paid_amount: input.amount,
389
- fee_amount: thisFee,
390
- status: 'paid',
391
- due_date: mov.due_date,
392
- payment_date: input.paymentDate,
393
- installment_number: mov.installment_number,
394
- payment_method_id: input.paymentMethodId ?? null,
395
- payment_method_type_id: input.paymentMethodTypeId ?? null,
396
- bank_account_id: input.bankAccountId ?? null,
397
- cash_session_id: input.cashSessionId ?? null,
398
- card_brand: input.cardBrand ?? null,
399
- card_installments: input.cardInstallments ?? null,
400
- notes: input.notes ?? null,
401
- }
402
- let { data, error } = await pub.from(T.movements).insert(paymentRow).select().single()
403
- // Graceful fallback if the fee_amount migration hasn't been applied yet.
404
- if (error && /fee_amount/i.test(error.message ?? '')) {
405
- const { fee_amount, ...rest } = paymentRow
406
- ;({ data, error } = await pub.from(T.movements).insert(rest).select().single())
407
- }
408
- if (error) throw error
409
-
410
- // 2. Update the bill (obligation) cache: cumulative paid + status. It stays a
411
- // pure obligation (no bank/method/date) — the cash lives on the payment row.
412
- // v_invoice_balances reads bill.paid_amount, so this keeps balances correct.
413
- const newPaid = round2((mov.paid_amount ?? 0) + input.amount)
414
- const billStatus = newPaid >= mov.amount ? 'paid' : 'partial'
415
- // Clear the bill's cash fields so it stops being a cash event (the payment row is now
416
- // the cash event) — prevents double-counting legacy in-place-paid bills.
417
- await pub.from(T.movements)
418
- .update({ paid_amount: newPaid, status: billStatus, payment_date: null, bank_account_id: null })
419
- .eq('id', mov.id)
420
-
421
- // 3. Update invoice (order) status based on its bills
422
- if (mov.invoice_id) {
423
- const { data: allMovs } = await pub.from(T.movements)
424
- .select('status, amount, paid_amount')
425
- .eq('invoice_id', mov.invoice_id)
426
- .eq('movement_kind', 'bill')
427
- const movs = allMovs ?? []
428
- const allPaid = movs.length > 0 && movs.every((m: any) => m.status === 'paid')
429
- const anyPaid = movs.some((m: any) => m.status === 'paid' || m.status === 'partial')
430
- const invoiceStatus = allPaid ? 'paid' : anyPaid ? 'partial' : 'open'
431
- const totalPaid = movs.reduce((sum: number, m: any) => sum + (m.paid_amount ?? 0), 0)
432
- // Store paid amount in metadata (orders table has no dedicated paid column)
433
- const { data: order } = await core.from('orders').select('metadata').eq('id', mov.invoice_id).single()
434
- const meta = (order?.metadata as any) ?? {}
435
- const invoiceStage = allPaid ? 'paid' : anyPaid ? 'partial' : 'invoiced'
436
- // Only update financial order status. Agenda status lives on public.appointments.
437
- await core.from('orders').update({
438
- status: invoiceStage,
439
- metadata: { ...meta, paidAmount: totalPaid },
440
- }).eq('id', mov.invoice_id)
441
- }
442
-
443
- return snakeToCamel(data!) as unknown as FinancialMovement
444
- },
445
-
446
- async cancelMovement(id: string): Promise<void> {
447
- const { core, pub } = getClients()
448
- await pub.from(T.movements).update({ status: 'cancelled' }).eq('id', id)
449
- },
450
-
451
- // --- Transfers (between accounts) ---
452
- async createTransfer(input: CreateTransferInput): Promise<TransferResult> {
453
- const { pub } = getClients()
454
- if (!input.fromAccountId || !input.toAccountId || input.fromAccountId === input.toAccountId) {
455
- throw new Error('Source and destination accounts must differ')
456
- }
457
- if (!(input.amount > 0)) throw new Error('Amount must be greater than zero')
458
- const tenantId = getTenantId()
459
- const transferId = genId()
460
- const base = {
461
- tenant_id: tenantId,
462
- movement_kind: 'transfer' as const,
463
- amount: input.amount,
464
- paid_amount: input.amount,
465
- fee_amount: 0,
466
- status: 'paid' as const,
467
- due_date: input.date,
468
- payment_date: input.date,
469
- notes: input.notes ?? null,
470
- }
471
- const rowsToInsert = [
472
- {
473
- ...base,
474
- direction: 'debit',
475
- bank_account_id: input.fromAccountId,
476
- metadata: { transferId, transferRole: 'out', counterAccountId: input.toAccountId },
477
- },
478
- {
479
- ...base,
480
- direction: 'credit',
481
- bank_account_id: input.toAccountId,
482
- metadata: { transferId, transferRole: 'in', counterAccountId: input.fromAccountId },
483
- },
484
- ]
485
- let { data, error } = await pub.from(T.movements).insert(rowsToInsert).select('id, direction')
486
- // Graceful fallback if the fee_amount migration hasn't been applied yet.
487
- if (error && /fee_amount/i.test(error.message ?? '')) {
488
- const stripped = rowsToInsert.map(({ fee_amount, ...rest }) => rest)
489
- ;({ data, error } = await pub.from(T.movements).insert(stripped).select('id, direction'))
490
- }
491
- if (error) throw error
492
- const rows = (data ?? []) as Array<{ id: string; direction: string }>
493
- const debitMovementId = rows.find((r) => r.direction === 'debit')?.id ?? ''
494
- const creditMovementId = rows.find((r) => r.direction === 'credit')?.id ?? ''
495
- return { transferId, debitMovementId, creditMovementId }
496
- },
497
-
498
- // --- Bank Accounts ---
499
- async getBankAccounts(): Promise<BankAccount[]> {
500
- const { core, pub } = getClients()
501
- const { data } = await pub.from(T.bankAccounts).select('*').eq('is_active', true).order('name')
502
- return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as BankAccount)
503
- },
504
-
505
- async createBankAccount(input: CreateBankAccountInput): Promise<BankAccount> {
506
- const { core, pub } = getClients()
507
- const tenantId = getTenantId()
508
- const { data } = await pub.from(T.bankAccounts).insert({
509
- ...camelToSnake(input as any), tenant_id: tenantId, current_balance: input.initialBalance ?? 0,
510
- }).select().single()
511
- return snakeToCamel(data!) as unknown as BankAccount
512
- },
513
-
514
- async updateBankAccount(id: string, partial: Partial<BankAccount>): Promise<BankAccount> {
515
- const { core, pub } = getClients()
516
- const row = camelToSnake(partial as any)
517
- delete row.id; delete row.tenant_id
518
- const { data } = await pub.from(T.bankAccounts).update(row).eq('id', id).select().single()
519
- return snakeToCamel(data!) as unknown as BankAccount
520
- },
521
-
522
- // --- Cash Sessions ---
523
- async getCashSessions(bankAccountId?: string): Promise<CashSession[]> {
524
- const { core, pub } = getClients()
525
- let qb = pub.from(T.cashRegisterSessions).select('*')
526
- if (bankAccountId) qb = qb.eq('bank_account_id', bankAccountId)
527
- const { data } = await qb.order('opened_at', { ascending: false })
528
- return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as CashSession)
529
- },
530
-
531
- async getOpenSession(bankAccountId: string): Promise<CashSession | null> {
532
- const { core, pub } = getClients()
533
- const { data } = await pub.from(T.cashRegisterSessions).select('*').eq('bank_account_id', bankAccountId).eq('status', 'open').single()
534
- return data ? snakeToCamel(data) as unknown as CashSession : null
535
- },
536
-
537
- async openCashSession(input: OpenCashSessionInput): Promise<CashSession> {
538
- const { core, pub } = getClients()
539
- const tenantId = getTenantId()
540
- const { data } = await pub.from(T.cashRegisterSessions).insert({
541
- tenant_id: tenantId, bank_account_id: input.bankAccountId,
542
- status: 'open', opening_balance: input.openingBalance,
543
- opened_by_user_id: input.openedByUserId, opened_by_name: input.openedByName,
544
- unit_id: input.unitId,
545
- }).select().single()
546
- return snakeToCamel(data!) as unknown as CashSession
547
- },
548
-
549
- async closeCashSession(input: CloseCashSessionInput): Promise<CashSession> {
550
- const { core, pub } = getClients()
551
- const { data } = await pub.from(T.cashRegisterSessions).update({
552
- status: 'closed', closing_balance: input.closingBalance,
553
- closed_at: new Date().toISOString(),
554
- closed_by_user_id: input.closedByUserId, closed_by_name: input.closedByName,
555
- notes: input.notes,
556
- }).eq('id', input.sessionId).select().single()
557
- return snakeToCamel(data!) as unknown as CashSession
558
- },
559
-
560
- async getCashSessionSummary(sessionId: string): Promise<CashSessionSummary> {
561
- const { core, pub } = getClients()
562
- const session = (await pub.from(T.cashRegisterSessions).select('*').eq('id', sessionId).single()).data
563
- const { data: movs } = await pub.from(T.movements).select('direction, paid_amount').eq('cash_session_id', sessionId).eq('status', 'paid')
564
- const movements = movs ?? []
565
- return {
566
- session: snakeToCamel(session!) as unknown as CashSession,
567
- movementCount: movements.length,
568
- totalInflow: movements.filter((m: any) => m.direction === 'credit').reduce((s: number, m: any) => s + m.paid_amount, 0),
569
- totalOutflow: movements.filter((m: any) => m.direction === 'debit').reduce((s: number, m: any) => s + m.paid_amount, 0),
570
- }
571
- },
572
-
573
- // --- Payment Methods ---
574
- async getPaymentMethods(): Promise<PaymentMethod[]> {
575
- const { core, pub } = getClients()
576
- const { data } = await pub.from(T.paymentMethods).select('*').eq('is_active', true).order('name')
577
- return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as PaymentMethod)
578
- },
579
-
580
- async getPaymentMethodTypes(): Promise<PaymentMethodType[]> {
581
- const { core, pub } = getClients()
582
- const { data } = await pub.from(T.paymentMethodTypes).select('*').eq('is_active', true).order('name')
583
- return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as PaymentMethodType)
584
- },
585
-
586
- // --- Chart of Accounts ---
587
- async getChartOfAccounts(): Promise<ChartOfAccountsNode[]> {
588
- const { core, pub } = getClients()
589
- const { data } = await pub.from(T.chartOfAccounts).select('*').eq('is_active', true).order('code')
590
- return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as ChartOfAccountsNode)
591
- },
592
-
593
- async getCostCenters(): Promise<CostCenter[]> {
594
- const { core, pub } = getClients()
595
- const { data } = await pub.from(T.costCenters).select('*').eq('is_active', true).order('code')
596
- return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as CostCenter)
597
- },
598
-
599
- // --- Card Transactions ---
600
- async getCardTransactions(dateRange?: DateRange): Promise<CardTransaction[]> {
601
- const { core, pub } = getClients()
602
- return [] // TODO: implement when card_transactions table exists
603
- },
604
-
605
- // --- Statements (ERP extract: opening balance + realized cash + per-row net/fee) ---
606
- async getStatement(query: StatementQuery): Promise<StatementResult> {
607
- const { core, pub } = getClients()
608
- const today = new Date().toISOString().slice(0, 10)
609
- // The extract = actual cash events. A cash event is any non-cancelled movement with
610
- // a payment_date: payment movements, transfers, and legacy in-place-paid bills (which
611
- // still carry a payment_date until the split migration backfills them). Once a payment
612
- // movement exists for a bill, payMovement clears the bill's payment_date, so a single
613
- // cash amount is represented by exactly one row — no double counting, no migration needed.
614
-
615
- // 1. Resolve in-scope accounts. No bankAccountId => consolidated: ALL realized cash
616
- // (including movements with no bank_account_id, e.g. cash/card payments not tied to an account).
617
- const { data: acctRows } = await pub.from(T.bankAccounts).select('id, name, initial_balance, is_active')
618
- const allAccounts = (acctRows ?? []) as any[]
619
- const consolidated = !query.bankAccountId
620
- const scopeAccounts = consolidated
621
- ? allAccounts.filter((a) => a.is_active)
622
- : allAccounts.filter((a) => a.id === query.bankAccountId)
623
- const accountIds = scopeAccounts.map((a) => a.id)
624
- const accountNameById = new Map<string, string>(allAccounts.map((a) => [a.id, a.name]))
625
- const initialSum = scopeAccounts.reduce((s, a) => s + (Number(a.initial_balance) || 0), 0)
626
- // Only bail when a specific account was asked for and not found.
627
- if (!consolidated && accountIds.length === 0) {
628
- return { entries: [], openingBalance: 0, closingBalance: 0, totalCredits: 0, totalDebits: 0, totalFees: 0, net: 0, accountId: query.bankAccountId }
629
- }
630
- // Consolidated => no account filter (catches NULL-account movements too).
631
- const scopeFilter = (qb: any) => consolidated ? qb : qb.in('bank_account_id', accountIds)
632
-
633
- const netOf = (r: any) => r.direction === 'credit'
634
- ? (Number(r.paid_amount) || 0) - (Number(r.fee_amount) || 0)
635
- : -(Number(r.paid_amount) || 0)
636
-
637
- // 2. Opening balance = Σ initial_balance + prior net (payment_date < from).
638
- // Select '*' so this still works before the fee_amount migration lands (fee → 0).
639
- const { data: priorRows } = await scopeFilter(pub.from(T.movements)
640
- .select('*')
641
- .neq('status', 'cancelled')
642
- .lt('payment_date', query.dateRange.from))
643
- const priorNet = (priorRows ?? []).reduce((s: number, r: any) => s + netOf(r), 0)
644
- const openingBalance = round2(initialSum + priorNet)
645
-
646
- // 3. In-range realized rows.
647
- const { data: movs } = await scopeFilter(pub.from(T.movements)
648
- .select('*')
649
- .neq('status', 'cancelled')
650
- .gte('payment_date', query.dateRange.from).lte('payment_date', query.dateRange.to)
651
- .order('payment_date').order('created_at'))
652
-
653
- // 4. Join invoices for descriptions.
654
- const invoiceIds = [...new Set((movs ?? []).map((r: any) => r.invoice_id).filter(Boolean))]
655
- const invoiceById = new Map<string, Invoice>()
656
- if (invoiceIds.length) {
657
- const { data: orders } = await core.from('orders').select('id, party_id, reference_number, metadata').in('id', invoiceIds)
658
- for (const o of (orders ?? []) as any[]) {
659
- invoiceById.set(o.id, { id: o.id, contactId: o.party_id, contactName: (o.metadata as any)?.contactName, fiscalNumber: o.reference_number } as Invoice)
660
- }
661
- }
662
-
663
- // 5. Build entries with running NET balance.
664
- let balance = openingBalance
665
- let totalCredits = 0, totalDebits = 0, totalFees = 0
666
- const entries: StatementEntry[] = (movs ?? []).map((r: any) => {
667
- const movement = snakeToCamel(r) as unknown as FinancialMovement
668
- const gross = Number(r.paid_amount) || 0
669
- const isCredit = r.direction === 'credit'
670
- const fee = isCredit ? (Number(r.fee_amount) || 0) : 0
671
- const net = isCredit ? round2(gross - fee) : gross
672
- const meta = (r.metadata ?? {}) as any
673
- const isTransfer = r.movement_kind === 'transfer'
674
- const entryKind: StatementEntry['entryKind'] = isTransfer
675
- ? (meta.transferRole === 'in' ? 'transfer-in' : 'transfer-out')
676
- : 'movement'
677
- const counterAccountId = isTransfer ? meta.counterAccountId : undefined
678
- if (isCredit) balance = round2(balance + net)
679
- else balance = round2(balance - net)
680
- // Exclude internal transfers from consolidated totals (they net to zero across accounts).
681
- if (!(consolidated && isTransfer)) {
682
- if (isCredit) totalCredits = round2(totalCredits + net)
683
- else totalDebits = round2(totalDebits + net)
684
- totalFees = round2(totalFees + fee)
685
- }
686
- return {
687
- movement,
688
- invoice: r.invoice_id ? invoiceById.get(r.invoice_id) : undefined,
689
- runningBalance: balance,
690
- gross, fee, net, entryKind,
691
- counterAccountId,
692
- counterAccountName: counterAccountId ? accountNameById.get(counterAccountId) : undefined,
693
- }
694
- })
695
-
696
- // 5b. Resolve the reconciliation counterpart for matched bank lines, so the
697
- // extract row can link to the internal transaction it was conciliated with.
698
- const matchedIds = [...new Set(entries.map((e) => e.movement.matchedMovementId).filter(Boolean))] as string[]
699
- if (matchedIds.length) {
700
- const { data: matched } = await pub.from(T.movements).select('id, invoice_id, notes').in('id', matchedIds)
701
- const matchedById = new Map<string, any>((matched ?? []).map((m: any) => [m.id, m]))
702
- const matchedInvoiceIds = [...new Set((matched ?? []).map((m: any) => m.invoice_id).filter(Boolean))] as string[]
703
- const invById = new Map<string, any>()
704
- if (matchedInvoiceIds.length) {
705
- const { data: orders } = await core.from('orders').select('id, reference_number, metadata').in('id', matchedInvoiceIds)
706
- for (const o of (orders ?? []) as any[]) invById.set(o.id, o)
707
- }
708
- for (const e of entries) {
709
- const mid = e.movement.matchedMovementId
710
- const mm = mid ? matchedById.get(mid) : undefined
711
- if (!mm) continue
712
- const inv = mm.invoice_id ? invById.get(mm.invoice_id) : undefined
713
- e.reconciledWith = {
714
- movementId: mm.id,
715
- description: mm.notes ?? undefined,
716
- invoiceId: mm.invoice_id ?? undefined,
717
- fiscalNumber: inv?.reference_number ?? undefined,
718
- contactName: (inv?.metadata as any)?.contactName ?? undefined,
719
- }
720
- }
721
- }
722
-
723
- // 6. Account current balance (ledger as-of-today).
724
- const { data: todayRows } = await scopeFilter(pub.from(T.movements)
725
- .select('*')
726
- .neq('status', 'cancelled')
727
- .lte('payment_date', today))
728
- const accountCurrentBalance = round2(initialSum + (todayRows ?? []).reduce((s: number, r: any) => s + netOf(r), 0))
729
-
730
- return {
731
- entries,
732
- openingBalance,
733
- closingBalance: balance,
734
- totalCredits,
735
- totalDebits,
736
- totalFees,
737
- net: round2(totalCredits - totalDebits),
738
- accountId: query.bankAccountId,
739
- accountCurrentBalance,
740
- }
741
- },
742
-
743
- // --- Summary ---
744
- async getSummary(dateRange?: DateRange): Promise<FinancialSummary> {
745
- // Sweep overdue on first load (background, non-blocking)
746
- sweepOverdue()
747
- const { core, pub } = getClients()
748
- const { data: accounts } = await pub.from(T.bankAccounts).select('current_balance').eq('is_active', true)
749
- const totalBalance = (accounts ?? []).reduce((s: number, a: any) => s + (a.current_balance ?? 0), 0)
750
-
751
- const { data: movs } = await pub.from(T.movements).select('direction, movement_kind, amount, paid_amount, status, due_date, payment_date')
752
- const allMovs = movs ?? []
753
- const unpaid = allMovs.filter((m: any) => ['pending', 'partial', 'overdue'].includes(m.status))
754
- const today = new Date().toISOString().slice(0, 10)
755
-
756
- const unpaidCredit = unpaid.filter((m: any) => m.direction === 'credit')
757
- const unpaidDebit = unpaid.filter((m: any) => m.direction === 'debit')
758
- const overdueCredit = unpaidCredit.filter((m: any) => m.status === 'overdue' || m.due_date < today)
759
- const overdueDebit = unpaidDebit.filter((m: any) => m.status === 'overdue' || m.due_date < today)
760
-
761
- // Monthly cash flow = a trailing 30-day rolling window (not calendar
762
- // month-to-date, which reads empty in the first days of a month).
763
- const monthAgo = new Date()
764
- monthAgo.setDate(monthAgo.getDate() - 30)
765
- const monthStart = monthAgo.toISOString().slice(0, 10)
766
- // Monthly cash flow = payment cash events (one row per payment), never bills.
767
- const monthPaid = allMovs.filter((m: any) => m.movement_kind === 'payment' && m.payment_date >= monthStart)
768
-
769
- return {
770
- totalBalance,
771
- totalReceivable: unpaidCredit.reduce((s: number, m: any) => s + m.amount - m.paid_amount, 0),
772
- totalPayable: unpaidDebit.reduce((s: number, m: any) => s + m.amount - m.paid_amount, 0),
773
- monthlyInflow: monthPaid.filter((m: any) => m.direction === 'credit').reduce((s: number, m: any) => s + m.paid_amount, 0),
774
- monthlyOutflow: monthPaid.filter((m: any) => m.direction === 'debit').reduce((s: number, m: any) => s + m.paid_amount, 0),
775
- overdueReceivableCount: overdueCredit.length,
776
- overdueReceivableAmount: overdueCredit.reduce((s: number, m: any) => s + m.amount - m.paid_amount, 0),
777
- overduePayableCount: overdueDebit.length,
778
- overduePayableAmount: overdueDebit.reduce((s: number, m: any) => s + m.amount - m.paid_amount, 0),
779
- }
780
- },
781
-
782
- // --- Reconciliation (conciliação) ---
783
- // Imported bank-statement lines live in financial_movements tagged with
784
- // (external_source, external_id), status='paid'. Reconciling links a line
785
- // to the internal movement it settles (or accepts it standalone).
786
- async importBankTransactions(input) {
787
- const { pub } = getClients()
788
- const tenantId = getTenantId()
789
- let imported = 0, duplicates = 0
790
- for (const line of input.lines) {
791
- const { error } = await pub.from(T.movements).insert({
792
- tenant_id: tenantId,
793
- direction: line.direction,
794
- movement_kind: 'payment',
795
- amount: line.amount,
796
- paid_amount: line.amount,
797
- status: 'paid',
798
- due_date: line.date,
799
- payment_date: line.date,
800
- bank_account_id: line.bankAccountId ?? input.bankAccountId ?? null,
801
- notes: line.description,
802
- external_id: line.externalId,
803
- external_source: line.externalSource,
804
- })
805
- if (error) {
806
- // 23505 = unique violation on uq_financial_movements_external → already imported.
807
- if ((error as any).code === '23505') duplicates++
808
- else throw error
809
- } else imported++
810
- }
811
- return { imported, duplicates }
812
- },
813
-
814
- async getUnreconciled(query) {
815
- const { pub } = getClients()
816
- let qb = pub.from(T.movements).select('*')
817
- .not('external_source', 'is', null)
818
- .is('reconciled_at', null)
819
- if (query?.bankAccountId) qb = qb.eq('bank_account_id', query.bankAccountId)
820
- if (query?.dateRange) qb = qb.gte('payment_date', query.dateRange.from).lte('payment_date', query.dateRange.to)
821
- const { data } = await qb.order('payment_date', { ascending: false })
822
- return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as FinancialMovement)
823
- },
824
-
825
- async suggestReconciliation(bankMovementId) {
826
- const { core, pub } = getClients()
827
- const { data: line } = await pub.from(T.movements).select('*').eq('id', bankMovementId).single()
828
- if (!line) return []
829
- const amount = Number(line.amount) || 0
830
- const lineDate = line.payment_date ?? line.due_date
831
- // Candidates: app-native (non-imported), same direction, not reconciled, amount within ±2%.
832
- const tol = Math.max(0.01, round2(amount * 0.02))
833
- const { data } = await pub.from(T.movements).select('*')
834
- .is('external_source', null)
835
- .is('reconciled_at', null)
836
- .eq('direction', line.direction)
837
- .gte('amount', round2(amount - tol))
838
- .lte('amount', round2(amount + tol))
839
- .neq('id', bankMovementId)
840
- .limit(25)
841
- const candidates = ((data ?? []) as any[]).map((r) => {
842
- const amtDiff = Math.abs((Number(r.amount) || 0) - amount)
843
- const amtScore = amount > 0 ? Math.max(0, 1 - amtDiff / amount) : 0
844
- const refDate = r.due_date ?? r.payment_date
845
- const days = lineDate && refDate
846
- ? Math.abs((new Date(lineDate).getTime() - new Date(refDate).getTime()) / 86400000)
847
- : 30
848
- const dateScore = Math.max(0, 1 - days / 30)
849
- return {
850
- movement: snakeToCamel(r) as unknown as FinancialMovement,
851
- score: round2(0.7 * amtScore + 0.3 * dateScore),
852
- } as ReconciliationCandidate
853
- }).sort((a, b) => b.score - a.score)
854
- // Attach invoice contact names for display.
855
- const invoiceIds = [...new Set(candidates.map((c) => c.movement.invoiceId).filter(Boolean))] as string[]
856
- if (invoiceIds.length) {
857
- const { data: orders } = await core.from('orders').select('id, reference_number, metadata').in('id', invoiceIds)
858
- const byId = new Map<string, any>((orders ?? []).map((o: any) => [o.id, o]))
859
- for (const c of candidates) {
860
- const o = c.movement.invoiceId ? byId.get(c.movement.invoiceId) : undefined
861
- if (o) c.invoice = { id: o.id, contactName: (o.metadata as any)?.contactName, fiscalNumber: o.reference_number } as Invoice
862
- }
863
- }
864
- return candidates
865
- },
866
-
867
- async reconcileMovement(input) {
868
- const { pub } = getClients()
869
- const now = new Date().toISOString()
870
- await pub.from(T.movements).update({
871
- reconciled_at: now,
872
- matched_movement_id: input.matchedMovementId ?? null,
873
- }).eq('id', input.bankMovementId)
874
- if (input.matchedMovementId) {
875
- // The internal movement is now settled+reconciled by the bank line.
876
- await pub.from(T.movements).update({ reconciled_at: now }).eq('id', input.matchedMovementId)
877
- }
878
- },
879
-
880
- async unreconcileMovement(bankMovementId) {
881
- const { pub } = getClients()
882
- const { data: line } = await pub.from(T.movements).select('matched_movement_id').eq('id', bankMovementId).single()
883
- await pub.from(T.movements).update({ reconciled_at: null, matched_movement_id: null }).eq('id', bankMovementId)
884
- if (line?.matched_movement_id) {
885
- await pub.from(T.movements).update({ reconciled_at: null }).eq('id', line.matched_movement_id)
886
- }
887
- },
888
-
889
- // --- Notas fiscais (NF-e / NFC-e / NFS-e) ---
890
- async getNotas(query: NotaFiscalQuery): Promise<PaginatedResult<NotaFiscal>> {
891
- const { pub } = getClients()
892
- let qb = pub.from(T.notas).select('*', { count: 'exact' })
893
- if (query.status) {
894
- const statuses = Array.isArray(query.status) ? query.status : [query.status]
895
- qb = qb.in('status', statuses)
896
- }
897
- if (query.model) qb = qb.eq('model', query.model)
898
- if (query.orderId) qb = qb.eq('order_id', query.orderId)
899
- if (query.search) qb = qb.or(`number.ilike.%${query.search}%,contact_name.ilike.%${query.search}%`)
900
- const page = query.page ?? 1
901
- const pageSize = query.pageSize ?? 50
902
- qb = qb.range((page - 1) * pageSize, page * pageSize - 1).order('created_at', { ascending: false })
903
- const { data, count } = await qb
904
- return { data: (data ?? []).map((r: any) => snakeToCamel(r) as unknown as NotaFiscal), total: count ?? 0 }
905
- },
906
-
907
- async getNotaById(id: string): Promise<NotaFiscal | null> {
908
- const { pub } = getClients()
909
- const { data } = await pub.from(T.notas).select('*').eq('id', id).maybeSingle()
910
- return data ? (snakeToCamel(data) as unknown as NotaFiscal) : null
911
- },
912
-
913
- async createNota(input: CreateNotaFiscalInput): Promise<NotaFiscal> {
914
- const { pub } = getClients()
915
- const row = {
916
- tenant_id: getTenantId(),
917
- model: input.model ?? 'nfe',
918
- status: 'draft',
919
- series: input.series ?? null,
920
- order_id: input.orderId ?? null,
921
- contact_id: input.contactId ?? null,
922
- contact_name: input.contactName ?? null,
923
- total_amount: input.totalAmount,
924
- observations: input.observations ?? null,
925
- }
926
- const { data, error } = await pub.from(T.notas).insert(row).select().single()
927
- if (error) throw error
928
- return snakeToCamel(data) as unknown as NotaFiscal
929
- },
930
-
931
- async updateNota(id: string, patch: Partial<NotaFiscal>): Promise<NotaFiscal> {
932
- const { pub } = getClients()
933
- const { data, error } = await pub.from(T.notas).update(camelToSnake(patch as Record<string, unknown>)).eq('id', id).select().single()
934
- if (error) throw error
935
- return snakeToCamel(data) as unknown as NotaFiscal
936
- },
937
-
938
- // Emission seam: no SEFAZ integration yet, so assign a número/série locally
939
- // and mark it issued. An external emissor replaces this body (or overrides
940
- // issueNota on a wrapping provider) without any UI change.
941
- async issueNota(id: string): Promise<NotaFiscal> {
942
- const { pub } = getClients()
943
- const { data: current } = await pub.from(T.notas).select('series').eq('id', id).single()
944
- const { count } = await pub.from(T.notas).select('id', { count: 'exact', head: true }).eq('status', 'issued')
945
- const patch = {
946
- status: 'issued',
947
- number: String((count ?? 0) + 1).padStart(6, '0'),
948
- series: current?.series ?? '1',
949
- issued_at: new Date().toISOString(),
950
- emissor: 'manual',
951
- }
952
- const { data, error } = await pub.from(T.notas).update(patch).eq('id', id).select().single()
953
- if (error) throw error
954
- return snakeToCamel(data) as unknown as NotaFiscal
955
- },
956
-
957
- async cancelNota(id: string, reason?: string): Promise<NotaFiscal> {
958
- const { pub } = getClients()
959
- const { data, error } = await pub.from(T.notas)
960
- .update({ status: 'cancelled', error_message: reason ?? null })
961
- .eq('id', id).select().single()
962
- if (error) throw error
963
- return snakeToCamel(data) as unknown as NotaFiscal
964
- },
965
- }
966
-
967
- return provider
968
- }