@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.
- package/README.md +3 -3
- package/dist/components/PersonStatementWidget.d.ts +1 -1
- package/dist/components/PersonStatementWidget.d.ts.map +1 -1
- package/dist/{FinancialContext.d.ts → context.d.ts} +4 -2
- package/dist/context.d.ts.map +1 -0
- package/dist/data/registries.d.ts.map +1 -0
- package/dist/index.d.ts +13 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1426 -1426
- package/dist/index.js.map +1 -1
- package/dist/integrations/stripe/connectorDef.d.ts +14 -0
- package/dist/integrations/stripe/connectorDef.d.ts.map +1 -0
- package/dist/integrations/stripe/data.d.ts +43 -0
- package/dist/integrations/stripe/data.d.ts.map +1 -0
- package/dist/lib/message-events.d.ts +12 -0
- package/dist/lib/message-events.d.ts.map +1 -0
- package/dist/{quick-add.d.ts → lib/quick-add.d.ts} +1 -1
- package/dist/lib/quick-add.d.ts.map +1 -0
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/{FinancialPage.d.ts → views/FinancialPage.d.ts} +3 -3
- package/dist/views/FinancialPage.d.ts.map +1 -0
- package/dist/views/InvoiceDetailView.d.ts.map +1 -1
- package/dist/views/InvoiceFormView.d.ts.map +1 -1
- package/dist/views/InvoiceListView.d.ts.map +1 -1
- package/dist/views/PayablesView.d.ts +1 -1
- package/dist/views/PayablesView.d.ts.map +1 -1
- package/dist/views/ReceivablesView.d.ts +1 -1
- package/dist/views/ReceivablesView.d.ts.map +1 -1
- package/dist/views/StatementsView.d.ts.map +1 -1
- package/dist/views/dashboardWidgets.d.ts +1 -1
- package/dist/views/dashboardWidgets.d.ts.map +1 -1
- package/package.json +12 -14
- package/dist/FinancialContext.d.ts.map +0 -1
- package/dist/FinancialPage.d.ts.map +0 -1
- package/dist/contract.test.d.ts +0 -2
- package/dist/contract.test.d.ts.map +0 -1
- package/dist/quick-add.d.ts.map +0 -1
- package/dist/registries.d.ts.map +0 -1
- package/src/FinancialContext.tsx +0 -59
- package/src/FinancialPage.tsx +0 -413
- package/src/README.md +0 -155
- package/src/components/FinancialGeneralSettings.tsx +0 -39
- package/src/components/FinancialOnboarding.tsx +0 -87
- package/src/components/FinancialSettings.tsx +0 -31
- package/src/components/PaymentModal.tsx +0 -369
- package/src/components/PersonStatementWidget.tsx +0 -133
- package/src/components/TransferModal.tsx +0 -150
- package/src/contract.test.ts +0 -58
- package/src/data/capability.test.ts +0 -135
- package/src/data/index.ts +0 -3
- package/src/data/mock.ts +0 -842
- package/src/data/supabase.ts +0 -968
- package/src/data/tables.ts +0 -13
- package/src/data/types.ts +0 -86
- package/src/index.ts +0 -487
- package/src/lib/tenant.ts +0 -3
- package/src/locales/en.ts +0 -493
- package/src/locales/index.ts +0 -7
- package/src/locales/pt-BR.ts +0 -493
- package/src/migrations/000_plg_rename.sql +0 -30
- package/src/migrations/001_financial_base.sql +0 -109
- package/src/migrations/002_chart_of_accounts.sql +0 -28
- package/src/migrations/003_card_brands.sql +0 -12
- package/src/migrations/004_order_to_cash.sql +0 -178
- package/src/migrations/005_seed_defaults.sql +0 -38
- package/src/migrations/006_rls_policies.sql +0 -37
- package/src/migrations/006b_extract_fee_amount.sql +0 -20
- package/src/migrations/007_reconciliation.sql +0 -32
- package/src/migrations/007b_movement_payment_method_type.sql +0 -6
- package/src/migrations/008_split_payment_movements.sql +0 -29
- package/src/migrations/009_agent_rpcs.sql +0 -165
- package/src/migrations/010_invoice_internal.sql +0 -133
- package/src/migrations/011_nfe.sql +0 -58
- package/src/migrations/index.ts +0 -923
- package/src/quick-add.ts +0 -58
- package/src/registries.ts +0 -308
- package/src/store.ts +0 -329
- package/src/types.ts +0 -592
- package/src/views/CardsView.tsx +0 -213
- package/src/views/CashRegistersView.tsx +0 -597
- package/src/views/CommissionsView.tsx +0 -18
- package/src/views/InvoiceDetailView.tsx +0 -616
- package/src/views/InvoiceFormView.tsx +0 -945
- package/src/views/InvoiceListView.tsx +0 -323
- package/src/views/NotasFiscaisView.tsx +0 -209
- package/src/views/PayablesView.tsx +0 -48
- package/src/views/QuickTransactionForm.tsx +0 -548
- package/src/views/ReceivablesView.tsx +0 -48
- package/src/views/ReconciliationView.tsx +0 -211
- package/src/views/StatementsView.tsx +0 -335
- package/src/views/SummaryView.tsx +0 -203
- package/src/views/dashboardWidgets.tsx +0 -437
- package/src/views/navReferrer.ts +0 -24
- /package/dist/{registries.d.ts → data/registries.d.ts} +0 -0
package/src/data/supabase.ts
DELETED
|
@@ -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
|
-
}
|