@fayz-ai/plugin-financial 0.1.1
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/dist/FinancialContext.d.ts +44 -0
- package/dist/FinancialContext.d.ts.map +1 -0
- package/dist/FinancialPage.d.ts +18 -0
- package/dist/FinancialPage.d.ts.map +1 -0
- package/dist/components/FinancialGeneralSettings.d.ts +3 -0
- package/dist/components/FinancialGeneralSettings.d.ts.map +1 -0
- package/dist/components/FinancialOnboarding.d.ts +5 -0
- package/dist/components/FinancialOnboarding.d.ts.map +1 -0
- package/dist/components/FinancialSettings.d.ts +8 -0
- package/dist/components/FinancialSettings.d.ts.map +1 -0
- package/dist/components/PaymentModal.d.ts +8 -0
- package/dist/components/PaymentModal.d.ts.map +1 -0
- package/dist/data/index.d.ts +3 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/mock.d.ts +9 -0
- package/dist/data/mock.d.ts.map +1 -0
- package/dist/data/supabase.d.ts +3 -0
- package/dist/data/supabase.d.ts.map +1 -0
- package/dist/data/types.d.ts +27 -0
- package/dist/data/types.d.ts.map +1 -0
- package/dist/index.cjs +4937 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +91 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4930 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/tenant.d.ts +3 -0
- package/dist/lib/tenant.d.ts.map +1 -0
- package/dist/locales/en.d.ts +2 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/index.d.ts +2 -0
- package/dist/locales/index.d.ts.map +1 -0
- package/dist/locales/pt-BR.d.ts +2 -0
- package/dist/locales/pt-BR.d.ts.map +1 -0
- package/dist/registries.d.ts +3 -0
- package/dist/registries.d.ts.map +1 -0
- package/dist/store.d.ts +38 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/types.d.ts +302 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/views/CardsView.d.ts +3 -0
- package/dist/views/CardsView.d.ts.map +1 -0
- package/dist/views/CashRegistersView.d.ts +3 -0
- package/dist/views/CashRegistersView.d.ts.map +1 -0
- package/dist/views/CommissionsView.d.ts +3 -0
- package/dist/views/CommissionsView.d.ts.map +1 -0
- package/dist/views/InvoiceDetailView.d.ts +9 -0
- package/dist/views/InvoiceDetailView.d.ts.map +1 -0
- package/dist/views/InvoiceFormView.d.ts +13 -0
- package/dist/views/InvoiceFormView.d.ts.map +1 -0
- package/dist/views/InvoiceListView.d.ts +8 -0
- package/dist/views/InvoiceListView.d.ts.map +1 -0
- package/dist/views/PayablesView.d.ts +7 -0
- package/dist/views/PayablesView.d.ts.map +1 -0
- package/dist/views/ReceivablesView.d.ts +7 -0
- package/dist/views/ReceivablesView.d.ts.map +1 -0
- package/dist/views/StatementsView.d.ts +3 -0
- package/dist/views/StatementsView.d.ts.map +1 -0
- package/dist/views/SummaryView.d.ts +9 -0
- package/dist/views/SummaryView.d.ts.map +1 -0
- package/dist/views/dashboardWidgets.d.ts +11 -0
- package/dist/views/dashboardWidgets.d.ts.map +1 -0
- package/package.json +55 -0
- package/src/FinancialContext.tsx +47 -0
- package/src/FinancialPage.tsx +261 -0
- package/src/README.md +155 -0
- package/src/components/FinancialGeneralSettings.tsx +26 -0
- package/src/components/FinancialOnboarding.tsx +87 -0
- package/src/components/FinancialSettings.tsx +31 -0
- package/src/components/PaymentModal.tsx +322 -0
- package/src/data/index.ts +2 -0
- package/src/data/mock.ts +520 -0
- package/src/data/supabase.ts +509 -0
- package/src/data/types.ts +53 -0
- package/src/index.ts +307 -0
- package/src/lib/tenant.ts +3 -0
- package/src/locales/en.ts +331 -0
- package/src/locales/index.ts +7 -0
- package/src/locales/pt-BR.ts +331 -0
- package/src/migrations/001_financial_base.sql +109 -0
- package/src/migrations/002_chart_of_accounts.sql +28 -0
- package/src/migrations/003_card_brands.sql +12 -0
- package/src/migrations/004_order_to_cash.sql +164 -0
- package/src/migrations/005_seed_defaults.sql +38 -0
- package/src/registries.ts +305 -0
- package/src/store.ts +221 -0
- package/src/types.ts +359 -0
- package/src/views/CardsView.tsx +74 -0
- package/src/views/CashRegistersView.tsx +597 -0
- package/src/views/CommissionsView.tsx +18 -0
- package/src/views/InvoiceDetailView.tsx +595 -0
- package/src/views/InvoiceFormView.tsx +900 -0
- package/src/views/InvoiceListView.tsx +177 -0
- package/src/views/PayablesView.tsx +37 -0
- package/src/views/ReceivablesView.tsx +37 -0
- package/src/views/StatementsView.tsx +123 -0
- package/src/views/SummaryView.tsx +12 -0
- package/src/views/dashboardWidgets.tsx +191 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import type { FinancialDataProvider } from './types'
|
|
2
|
+
import type {
|
|
3
|
+
Invoice, InvoiceItem, FinancialMovement, BankAccount,
|
|
4
|
+
CashSession, PaymentMethod, PaymentMethodType, ChartOfAccountsNode,
|
|
5
|
+
CardTransaction,
|
|
6
|
+
CreateInvoiceInput, PayMovementInput,
|
|
7
|
+
OpenCashSessionInput, CloseCashSessionInput, CreateBankAccountInput,
|
|
8
|
+
InvoiceQuery, MovementQuery, StatementQuery,
|
|
9
|
+
PaginatedResult, FinancialSummary, StatementEntry, CashSessionSummary,
|
|
10
|
+
DateRange,
|
|
11
|
+
} from '../types'
|
|
12
|
+
import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
|
|
13
|
+
import { getFinancialTenantId } from '../lib/tenant'
|
|
14
|
+
|
|
15
|
+
function getTenantId(): string | undefined {
|
|
16
|
+
// Local override wins; else use the app's active tenant so writes pass RLS.
|
|
17
|
+
return getFinancialTenantId() ?? getActiveTenantId()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function snakeToCamel(obj: Record<string, unknown>): Record<string, unknown> {
|
|
21
|
+
const result: Record<string, unknown> = {}
|
|
22
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
23
|
+
result[key.replace(/_([a-z])/g, (_, c) => c.toUpperCase())] = value
|
|
24
|
+
}
|
|
25
|
+
return result
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function camelToSnake(obj: Record<string, unknown>): Record<string, unknown> {
|
|
29
|
+
const result: Record<string, unknown> = {}
|
|
30
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
31
|
+
if (key.startsWith('_')) continue
|
|
32
|
+
result[key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`)] = value
|
|
33
|
+
}
|
|
34
|
+
return result
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getClients() {
|
|
38
|
+
const supabase = getSupabaseClientOptional() as any
|
|
39
|
+
if (!supabase) throw new Error('Supabase not initialized')
|
|
40
|
+
return { core: supabase.schema('saas_core'), pub: supabase }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Map order stage to InvoiceStatus for the financial UI */
|
|
44
|
+
function mapStageToInvoiceStatus(stage?: string): string {
|
|
45
|
+
switch (stage) {
|
|
46
|
+
case 'paid': return 'paid'
|
|
47
|
+
case 'partial': return 'partial'
|
|
48
|
+
case 'overdue': return 'overdue'
|
|
49
|
+
case 'cancelled': return 'cancelled'
|
|
50
|
+
case 'invoiced': return 'open'
|
|
51
|
+
case 'booked': return 'open'
|
|
52
|
+
default: return 'open'
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let _overdueSwept = false
|
|
57
|
+
|
|
58
|
+
/** Batch-mark overdue: pending/partial movements past due → 'overdue', then update parent invoices */
|
|
59
|
+
async function sweepOverdue() {
|
|
60
|
+
if (_overdueSwept) return
|
|
61
|
+
_overdueSwept = true
|
|
62
|
+
try {
|
|
63
|
+
const { core, pub } = getClients()
|
|
64
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
65
|
+
|
|
66
|
+
// Mark overdue movements
|
|
67
|
+
const { data: overdueMovs } = await pub.from('financial_movements')
|
|
68
|
+
.update({ status: 'overdue' })
|
|
69
|
+
.in('status', ['pending', 'partial'])
|
|
70
|
+
.lt('due_date', today)
|
|
71
|
+
.select('invoice_id')
|
|
72
|
+
|
|
73
|
+
// Collect unique invoice IDs and update their status
|
|
74
|
+
if (overdueMovs && overdueMovs.length > 0) {
|
|
75
|
+
const invoiceIds = [...new Set(overdueMovs.map((m: any) => m.invoice_id).filter(Boolean))]
|
|
76
|
+
for (const invId of invoiceIds) {
|
|
77
|
+
const { data: allMovs } = await pub.from('financial_movements')
|
|
78
|
+
.select('status')
|
|
79
|
+
.eq('invoice_id', invId)
|
|
80
|
+
.eq('movement_kind', 'bill')
|
|
81
|
+
const movs = allMovs ?? []
|
|
82
|
+
const hasOverdue = movs.some((m: any) => m.status === 'overdue')
|
|
83
|
+
const allPaid = movs.every((m: any) => m.status === 'paid')
|
|
84
|
+
const invoiceStatus = allPaid ? 'paid' : hasOverdue ? 'overdue' : 'open'
|
|
85
|
+
const invoiceStage = allPaid ? 'paid' : hasOverdue ? 'overdue' : 'invoiced'
|
|
86
|
+
// Only update financial stage — don't overwrite agenda status
|
|
87
|
+
await core.from('orders').update({ stage: invoiceStage }).eq('id', invId)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// Non-critical — don't block the UI
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// S2 (data-model refactor): paid amount, balance and status are DERIVED from the
|
|
96
|
+
// append-only ledger via v_invoice_balances — the single source of truth — not
|
|
97
|
+
// from stored order.stage / metadata.paidAmount (which drift). See
|
|
98
|
+
// docs/dogfood-sprint/DATA-MODEL-REFACTOR.md.
|
|
99
|
+
async function fetchInvoiceBalances(
|
|
100
|
+
pub: any,
|
|
101
|
+
ids: string[],
|
|
102
|
+
): Promise<Map<string, { paid: number; balance: number; status: string }>> {
|
|
103
|
+
const map = new Map<string, { paid: number; balance: number; status: string }>()
|
|
104
|
+
if (!ids.length) return map
|
|
105
|
+
const { data } = await pub
|
|
106
|
+
.from('v_invoice_balances')
|
|
107
|
+
.select('invoice_id, paid, balance, status')
|
|
108
|
+
.in('invoice_id', ids)
|
|
109
|
+
for (const r of (data ?? []) as any[]) {
|
|
110
|
+
map.set(r.invoice_id, { paid: Number(r.paid) || 0, balance: Number(r.balance) || 0, status: r.status })
|
|
111
|
+
}
|
|
112
|
+
return map
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function createSupabaseFinancialProvider(): FinancialDataProvider {
|
|
116
|
+
const provider: FinancialDataProvider = {
|
|
117
|
+
// --- Invoices (orders at stage=invoiced/paid/partial/overdue/cancelled with direction) ---
|
|
118
|
+
async getInvoices(query: InvoiceQuery): Promise<PaginatedResult<Invoice>> {
|
|
119
|
+
const { core, pub } = getClients()
|
|
120
|
+
let qb = core.from('orders').select('*', { count: 'exact' })
|
|
121
|
+
|
|
122
|
+
// Show orders with financial relevance: booked (with amount) + invoiced stages
|
|
123
|
+
qb = qb.in('stage', ['booked', 'invoiced', 'paid', 'partial', 'overdue', 'cancelled'])
|
|
124
|
+
qb = qb.gt('total', 0)
|
|
125
|
+
|
|
126
|
+
// Filter by direction (credit=receivable, debit=payable)
|
|
127
|
+
if (query.direction) {
|
|
128
|
+
qb = qb.eq('direction', query.direction)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (query.status) {
|
|
132
|
+
const statuses = Array.isArray(query.status) ? query.status : [query.status]
|
|
133
|
+
qb = qb.in('status', statuses)
|
|
134
|
+
}
|
|
135
|
+
if (query.contactId) qb = qb.eq('party_id', query.contactId)
|
|
136
|
+
if (query.search) qb = qb.ilike('notes', `%${query.search}%`)
|
|
137
|
+
if (query.dateRange) qb = qb.gte('created_at', query.dateRange.from).lte('created_at', query.dateRange.to)
|
|
138
|
+
const page = query.page ?? 1
|
|
139
|
+
const pageSize = query.pageSize ?? 50
|
|
140
|
+
qb = qb.range((page - 1) * pageSize, page * pageSize - 1).order('created_at', { ascending: false })
|
|
141
|
+
const { data, count } = await qb
|
|
142
|
+
|
|
143
|
+
const bal = await fetchInvoiceBalances(pub, (data ?? []).map((r: any) => r.id))
|
|
144
|
+
const invoices: Invoice[] = (data ?? []).map((r: any) => {
|
|
145
|
+
const meta = r.metadata ?? {}
|
|
146
|
+
// Resolve direction from stage model or legacy kind
|
|
147
|
+
const dir = r.direction ?? (r.kind === 'invoice_payable' ? 'debit' : 'credit')
|
|
148
|
+
const b = bal.get(r.id)
|
|
149
|
+
return {
|
|
150
|
+
id: r.id,
|
|
151
|
+
direction: dir,
|
|
152
|
+
invoiceDate: r.created_at?.slice(0, 10) ?? '',
|
|
153
|
+
fiscalNumber: r.reference_number,
|
|
154
|
+
totalAmount: r.total ?? 0,
|
|
155
|
+
paidAmount: b?.paid ?? meta.paidAmount ?? 0,
|
|
156
|
+
status: b?.status ?? mapStageToInvoiceStatus(r.stage),
|
|
157
|
+
totalInstallments: meta.installmentCount ?? 1,
|
|
158
|
+
contactId: r.party_id,
|
|
159
|
+
contactName: meta.contactName,
|
|
160
|
+
observations: r.notes,
|
|
161
|
+
itemsSummary: meta.itemsSummary,
|
|
162
|
+
tenantId: r.tenant_id,
|
|
163
|
+
createdAt: r.created_at,
|
|
164
|
+
updatedAt: r.updated_at,
|
|
165
|
+
} as Invoice
|
|
166
|
+
})
|
|
167
|
+
return { data: invoices, total: count ?? 0 }
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
async getInvoiceById(id: string): Promise<Invoice | null> {
|
|
171
|
+
const { core, pub } = getClients()
|
|
172
|
+
const { data } = await core.from('orders').select('*').eq('id', id).single()
|
|
173
|
+
if (!data) return null
|
|
174
|
+
const o = snakeToCamel(data) as any
|
|
175
|
+
const meta = data.metadata ?? {}
|
|
176
|
+
// Paid amount + status are DERIVED from the ledger view (single source of truth).
|
|
177
|
+
const balance = (await fetchInvoiceBalances(pub, [id])).get(id)
|
|
178
|
+
const { data: movs } = await pub.from('financial_movements')
|
|
179
|
+
.select('paid_amount')
|
|
180
|
+
.eq('invoice_id', id)
|
|
181
|
+
.eq('movement_kind', 'bill')
|
|
182
|
+
return {
|
|
183
|
+
id: o.id,
|
|
184
|
+
direction: o.direction ?? (o.kind === 'invoice_payable' ? 'debit' : 'credit'),
|
|
185
|
+
invoiceDate: o.createdAt?.slice(0, 10) ?? '',
|
|
186
|
+
fiscalNumber: o.referenceNumber,
|
|
187
|
+
totalAmount: o.total ?? 0, paidAmount: balance?.paid ?? 0,
|
|
188
|
+
status: balance?.status ?? mapStageToInvoiceStatus(o.stage),
|
|
189
|
+
totalInstallments: (movs ?? []).length || 1, contactId: o.partyId,
|
|
190
|
+
contactName: meta.contactName, itemsSummary: meta.itemsSummary,
|
|
191
|
+
bookingStartsAt: o.startsAt ?? undefined,
|
|
192
|
+
observations: o.notes,
|
|
193
|
+
tenantId: o.tenantId, createdAt: o.createdAt, updatedAt: o.updatedAt,
|
|
194
|
+
} as Invoice
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
async getInvoiceItems(invoiceId: string): Promise<InvoiceItem[]> {
|
|
198
|
+
const { core, pub } = getClients()
|
|
199
|
+
const { data } = await core.from('order_items').select('*').eq('order_id', invoiceId).order('sort_order')
|
|
200
|
+
return (data ?? []).map((r: any) => {
|
|
201
|
+
const i = snakeToCamel(r) as any
|
|
202
|
+
return {
|
|
203
|
+
id: i.id, invoiceId, itemKind: i.metadata?.itemKind ?? 'other',
|
|
204
|
+
description: i.name ?? '', quantity: i.quantity ?? 1,
|
|
205
|
+
unitPrice: i.unitPrice ?? 0, totalAmount: i.total ?? 0,
|
|
206
|
+
discount: i.discount ?? 0, surcharge: 0,
|
|
207
|
+
referenceId: i.productId ?? i.serviceId,
|
|
208
|
+
createdAt: i.createdAt,
|
|
209
|
+
} as InvoiceItem
|
|
210
|
+
})
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
async createInvoice(input: CreateInvoiceInput): Promise<Invoice> {
|
|
214
|
+
const { core, pub } = getClients()
|
|
215
|
+
const tenantId = getTenantId()
|
|
216
|
+
const kind = input.direction === 'debit' ? 'invoice_payable' : 'invoice_receivable'
|
|
217
|
+
const totalAmount = input.items.reduce((sum, item) => sum + item.quantity * item.unitPrice - (item.discount ?? 0) + (item.surcharge ?? 0), 0)
|
|
218
|
+
const itemsSummary = input.items.map((i: any) => i.description).filter(Boolean).join(', ')
|
|
219
|
+
|
|
220
|
+
// Auto-generate reference number if not provided
|
|
221
|
+
let referenceNumber = input.fiscalNumber
|
|
222
|
+
if (!referenceNumber && tenantId) {
|
|
223
|
+
const prefix = input.direction === 'credit' ? 'REC' : 'PAG'
|
|
224
|
+
const { data: seq } = await core.rpc('next_sequence', { p_tenant_id: tenantId, p_kind: kind })
|
|
225
|
+
if (seq) referenceNumber = `${prefix}-${String(seq).padStart(5, '0')}`
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const { data: order } = await core.from('orders').insert({
|
|
229
|
+
tenant_id: tenantId, kind, status: 'open',
|
|
230
|
+
stage: 'invoiced',
|
|
231
|
+
direction: input.direction,
|
|
232
|
+
reference_number: referenceNumber,
|
|
233
|
+
total: totalAmount, party_id: input.contactId,
|
|
234
|
+
notes: input.observations,
|
|
235
|
+
currency: 'BRL',
|
|
236
|
+
metadata: { itemsSummary, contactName: input.contactName, installmentCount: input.installments.length || 1 },
|
|
237
|
+
}).select().single()
|
|
238
|
+
|
|
239
|
+
if (order && input.items.length > 0) {
|
|
240
|
+
await core.from('order_items').insert(
|
|
241
|
+
input.items.map((item, i) => ({
|
|
242
|
+
order_id: order.id, name: item.description,
|
|
243
|
+
quantity: item.quantity, unit_price: item.unitPrice,
|
|
244
|
+
discount: item.discount ?? 0,
|
|
245
|
+
total: item.quantity * item.unitPrice - (item.discount ?? 0) + (item.surcharge ?? 0),
|
|
246
|
+
sort_order: i,
|
|
247
|
+
metadata: { itemKind: item.itemKind, referenceId: item.referenceId },
|
|
248
|
+
}))
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Create installment movements
|
|
253
|
+
if (order && input.installments.length > 0) {
|
|
254
|
+
await pub.from('financial_movements').insert(
|
|
255
|
+
input.installments.map((inst, i) => ({
|
|
256
|
+
tenant_id: tenantId, invoice_id: order.id,
|
|
257
|
+
direction: input.direction, movement_kind: inst.movementKind,
|
|
258
|
+
amount: inst.amount, paid_amount: 0, status: 'pending',
|
|
259
|
+
due_date: inst.dueDate, installment_number: inst.installmentNumber ?? i + 1,
|
|
260
|
+
}))
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
id: order!.id, direction: input.direction, invoiceDate: new Date().toISOString().slice(0, 10),
|
|
266
|
+
fiscalNumber: referenceNumber,
|
|
267
|
+
totalAmount, paidAmount: 0, status: 'open', totalInstallments: input.installments.length || 1,
|
|
268
|
+
contactName: input.contactName, itemsSummary,
|
|
269
|
+
tenantId: tenantId!, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
270
|
+
} as Invoice
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
async updateInvoice(id: string, partial: Partial<Invoice>): Promise<Invoice> {
|
|
274
|
+
const { core, pub } = getClients()
|
|
275
|
+
const row: any = {}
|
|
276
|
+
if (partial.observations) row.notes = partial.observations
|
|
277
|
+
if (partial.totalAmount) row.total = partial.totalAmount
|
|
278
|
+
if (partial.status) row.status = partial.status
|
|
279
|
+
if (Object.keys(row).length > 0) await core.from('orders').update(row).eq('id', id)
|
|
280
|
+
return (await provider.getInvoiceById(id))!
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
async cancelInvoice(id: string): Promise<void> {
|
|
284
|
+
const { core, pub } = getClients()
|
|
285
|
+
await core.from('orders').update({ status: 'cancelled' }).eq('id', id)
|
|
286
|
+
await pub.from('financial_movements').update({ status: 'cancelled' }).eq('invoice_id', id).eq('status', 'pending')
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
// --- Movements (public.financial_movements) ---
|
|
290
|
+
async getMovements(query: MovementQuery): Promise<PaginatedResult<FinancialMovement>> {
|
|
291
|
+
const { core, pub } = getClients()
|
|
292
|
+
let qb = pub.from('financial_movements').select('*', { count: 'exact' })
|
|
293
|
+
if (query.direction) qb = qb.eq('direction', query.direction)
|
|
294
|
+
if (query.status) {
|
|
295
|
+
const statuses = Array.isArray(query.status) ? query.status : [query.status]
|
|
296
|
+
qb = qb.in('status', statuses)
|
|
297
|
+
}
|
|
298
|
+
if (query.bankAccountId) qb = qb.eq('bank_account_id', query.bankAccountId)
|
|
299
|
+
if (query.cashSessionId) qb = qb.eq('cash_session_id', query.cashSessionId)
|
|
300
|
+
if (query.dateRange) qb = qb.gte('due_date', query.dateRange.from).lte('due_date', query.dateRange.to)
|
|
301
|
+
const page = query.page ?? 1
|
|
302
|
+
const pageSize = query.pageSize ?? 50
|
|
303
|
+
qb = qb.range((page - 1) * pageSize, page * pageSize - 1).order('due_date')
|
|
304
|
+
const { data, count } = await qb
|
|
305
|
+
return { data: (data ?? []).map((r: any) => snakeToCamel(r) as unknown as FinancialMovement), total: count ?? 0 }
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
async payMovement(input: PayMovementInput): Promise<FinancialMovement> {
|
|
309
|
+
const { core, pub } = getClients()
|
|
310
|
+
const { data: mov } = await pub.from('financial_movements').select('*').eq('id', input.movementId).single()
|
|
311
|
+
if (!mov) throw new Error('Movement not found')
|
|
312
|
+
const newPaid = (mov.paid_amount ?? 0) + input.amount
|
|
313
|
+
const newStatus = newPaid >= mov.amount ? 'paid' : 'partial'
|
|
314
|
+
const updates: any = { paid_amount: newPaid, status: newStatus, payment_date: input.paymentDate }
|
|
315
|
+
if (input.paymentMethodId) updates.payment_method_id = input.paymentMethodId
|
|
316
|
+
if (input.paymentMethodTypeId) updates.payment_method_type_id = input.paymentMethodTypeId
|
|
317
|
+
if (input.bankAccountId) updates.bank_account_id = input.bankAccountId
|
|
318
|
+
if (input.cashSessionId) updates.cash_session_id = input.cashSessionId
|
|
319
|
+
if (input.cardBrand) updates.card_brand = input.cardBrand
|
|
320
|
+
if (input.cardInstallments) updates.card_installments = input.cardInstallments
|
|
321
|
+
const { data } = await pub.from('financial_movements').update(updates).eq('id', input.movementId).select().single()
|
|
322
|
+
|
|
323
|
+
// Update invoice (order) status based on all its movements
|
|
324
|
+
if (mov.invoice_id) {
|
|
325
|
+
const { data: allMovs } = await pub.from('financial_movements')
|
|
326
|
+
.select('status, amount, paid_amount')
|
|
327
|
+
.eq('invoice_id', mov.invoice_id)
|
|
328
|
+
.eq('movement_kind', 'bill')
|
|
329
|
+
const movs = allMovs ?? []
|
|
330
|
+
const allPaid = movs.length > 0 && movs.every((m: any) => m.status === 'paid')
|
|
331
|
+
const anyPaid = movs.some((m: any) => m.status === 'paid' || m.status === 'partial')
|
|
332
|
+
const invoiceStatus = allPaid ? 'paid' : anyPaid ? 'partial' : 'open'
|
|
333
|
+
const totalPaid = movs.reduce((sum: number, m: any) => sum + (m.paid_amount ?? 0), 0)
|
|
334
|
+
// Store paid amount in metadata (orders table has no dedicated paid column)
|
|
335
|
+
const { data: order } = await core.from('orders').select('metadata').eq('id', mov.invoice_id).single()
|
|
336
|
+
const meta = (order?.metadata as any) ?? {}
|
|
337
|
+
const invoiceStage = allPaid ? 'paid' : anyPaid ? 'partial' : 'invoiced'
|
|
338
|
+
// Only update financial order status. Agenda status lives on saas_core.bookings.
|
|
339
|
+
await core.from('orders').update({
|
|
340
|
+
status: invoiceStage,
|
|
341
|
+
metadata: { ...meta, paidAmount: totalPaid },
|
|
342
|
+
}).eq('id', mov.invoice_id)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return snakeToCamel(data!) as unknown as FinancialMovement
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
async cancelMovement(id: string): Promise<void> {
|
|
349
|
+
const { core, pub } = getClients()
|
|
350
|
+
await pub.from('financial_movements').update({ status: 'cancelled' }).eq('id', id)
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
// --- Bank Accounts ---
|
|
354
|
+
async getBankAccounts(): Promise<BankAccount[]> {
|
|
355
|
+
const { core, pub } = getClients()
|
|
356
|
+
const { data } = await pub.from('bank_accounts').select('*').eq('is_active', true).order('name')
|
|
357
|
+
return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as BankAccount)
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
async createBankAccount(input: CreateBankAccountInput): Promise<BankAccount> {
|
|
361
|
+
const { core, pub } = getClients()
|
|
362
|
+
const tenantId = getTenantId()
|
|
363
|
+
const { data } = await pub.from('bank_accounts').insert({
|
|
364
|
+
...camelToSnake(input as any), tenant_id: tenantId, current_balance: input.initialBalance ?? 0,
|
|
365
|
+
}).select().single()
|
|
366
|
+
return snakeToCamel(data!) as unknown as BankAccount
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
async updateBankAccount(id: string, partial: Partial<BankAccount>): Promise<BankAccount> {
|
|
370
|
+
const { core, pub } = getClients()
|
|
371
|
+
const row = camelToSnake(partial as any)
|
|
372
|
+
delete row.id; delete row.tenant_id
|
|
373
|
+
const { data } = await pub.from('bank_accounts').update(row).eq('id', id).select().single()
|
|
374
|
+
return snakeToCamel(data!) as unknown as BankAccount
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
// --- Cash Sessions ---
|
|
378
|
+
async getCashSessions(bankAccountId?: string): Promise<CashSession[]> {
|
|
379
|
+
const { core, pub } = getClients()
|
|
380
|
+
let qb = pub.from('cash_register_sessions').select('*')
|
|
381
|
+
if (bankAccountId) qb = qb.eq('bank_account_id', bankAccountId)
|
|
382
|
+
const { data } = await qb.order('opened_at', { ascending: false })
|
|
383
|
+
return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as CashSession)
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
async getOpenSession(bankAccountId: string): Promise<CashSession | null> {
|
|
387
|
+
const { core, pub } = getClients()
|
|
388
|
+
const { data } = await pub.from('cash_register_sessions').select('*').eq('bank_account_id', bankAccountId).eq('status', 'open').single()
|
|
389
|
+
return data ? snakeToCamel(data) as unknown as CashSession : null
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
async openCashSession(input: OpenCashSessionInput): Promise<CashSession> {
|
|
393
|
+
const { core, pub } = getClients()
|
|
394
|
+
const tenantId = getTenantId()
|
|
395
|
+
const { data } = await pub.from('cash_register_sessions').insert({
|
|
396
|
+
tenant_id: tenantId, bank_account_id: input.bankAccountId,
|
|
397
|
+
status: 'open', opening_balance: input.openingBalance,
|
|
398
|
+
opened_by_user_id: input.openedByUserId, opened_by_name: input.openedByName,
|
|
399
|
+
unit_id: input.unitId,
|
|
400
|
+
}).select().single()
|
|
401
|
+
return snakeToCamel(data!) as unknown as CashSession
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
async closeCashSession(input: CloseCashSessionInput): Promise<CashSession> {
|
|
405
|
+
const { core, pub } = getClients()
|
|
406
|
+
const { data } = await pub.from('cash_register_sessions').update({
|
|
407
|
+
status: 'closed', closing_balance: input.closingBalance,
|
|
408
|
+
closed_at: new Date().toISOString(),
|
|
409
|
+
closed_by_user_id: input.closedByUserId, closed_by_name: input.closedByName,
|
|
410
|
+
notes: input.notes,
|
|
411
|
+
}).eq('id', input.sessionId).select().single()
|
|
412
|
+
return snakeToCamel(data!) as unknown as CashSession
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
async getCashSessionSummary(sessionId: string): Promise<CashSessionSummary> {
|
|
416
|
+
const { core, pub } = getClients()
|
|
417
|
+
const session = (await pub.from('cash_register_sessions').select('*').eq('id', sessionId).single()).data
|
|
418
|
+
const { data: movs } = await pub.from('financial_movements').select('direction, paid_amount').eq('cash_session_id', sessionId).eq('status', 'paid')
|
|
419
|
+
const movements = movs ?? []
|
|
420
|
+
return {
|
|
421
|
+
session: snakeToCamel(session!) as unknown as CashSession,
|
|
422
|
+
movementCount: movements.length,
|
|
423
|
+
totalInflow: movements.filter((m: any) => m.direction === 'credit').reduce((s: number, m: any) => s + m.paid_amount, 0),
|
|
424
|
+
totalOutflow: movements.filter((m: any) => m.direction === 'debit').reduce((s: number, m: any) => s + m.paid_amount, 0),
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
|
|
428
|
+
// --- Payment Methods ---
|
|
429
|
+
async getPaymentMethods(): Promise<PaymentMethod[]> {
|
|
430
|
+
const { core, pub } = getClients()
|
|
431
|
+
const { data } = await pub.from('payment_methods').select('*').eq('is_active', true).order('name')
|
|
432
|
+
return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as PaymentMethod)
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
async getPaymentMethodTypes(): Promise<PaymentMethodType[]> {
|
|
436
|
+
const { core, pub } = getClients()
|
|
437
|
+
const { data } = await pub.from('payment_method_types').select('*').eq('is_active', true).order('name')
|
|
438
|
+
return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as PaymentMethodType)
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
// --- Chart of Accounts ---
|
|
442
|
+
async getChartOfAccounts(): Promise<ChartOfAccountsNode[]> {
|
|
443
|
+
const { core, pub } = getClients()
|
|
444
|
+
const { data } = await pub.from('chart_of_accounts').select('*').eq('is_active', true).order('code')
|
|
445
|
+
return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as ChartOfAccountsNode)
|
|
446
|
+
},
|
|
447
|
+
|
|
448
|
+
// --- Card Transactions ---
|
|
449
|
+
async getCardTransactions(dateRange?: DateRange): Promise<CardTransaction[]> {
|
|
450
|
+
const { core, pub } = getClients()
|
|
451
|
+
return [] // TODO: implement when card_transactions table exists
|
|
452
|
+
},
|
|
453
|
+
|
|
454
|
+
// --- Statements ---
|
|
455
|
+
async getStatement(query: StatementQuery): Promise<StatementEntry[]> {
|
|
456
|
+
const { core, pub } = getClients()
|
|
457
|
+
const { data: movs } = await pub.from('financial_movements').select('*')
|
|
458
|
+
.eq('bank_account_id', query.bankAccountId).eq('status', 'paid')
|
|
459
|
+
.gte('payment_date', query.dateRange.from).lte('payment_date', query.dateRange.to)
|
|
460
|
+
.order('payment_date')
|
|
461
|
+
|
|
462
|
+
const { data: account } = await pub.from('bank_accounts').select('initial_balance').eq('id', query.bankAccountId).single()
|
|
463
|
+
let balance = account?.initial_balance ?? 0
|
|
464
|
+
|
|
465
|
+
return (movs ?? []).map((r: any) => {
|
|
466
|
+
const mov = snakeToCamel(r) as unknown as FinancialMovement
|
|
467
|
+
if (mov.direction === 'credit') balance += mov.paidAmount
|
|
468
|
+
else balance -= mov.paidAmount
|
|
469
|
+
return { movement: mov, runningBalance: balance }
|
|
470
|
+
})
|
|
471
|
+
},
|
|
472
|
+
|
|
473
|
+
// --- Summary ---
|
|
474
|
+
async getSummary(dateRange?: DateRange): Promise<FinancialSummary> {
|
|
475
|
+
// Sweep overdue on first load (background, non-blocking)
|
|
476
|
+
sweepOverdue()
|
|
477
|
+
const { core, pub } = getClients()
|
|
478
|
+
const { data: accounts } = await pub.from('bank_accounts').select('current_balance').eq('is_active', true)
|
|
479
|
+
const totalBalance = (accounts ?? []).reduce((s: number, a: any) => s + (a.current_balance ?? 0), 0)
|
|
480
|
+
|
|
481
|
+
const { data: movs } = await pub.from('financial_movements').select('direction, movement_kind, amount, paid_amount, status, due_date, payment_date')
|
|
482
|
+
const allMovs = movs ?? []
|
|
483
|
+
const unpaid = allMovs.filter((m: any) => ['pending', 'partial', 'overdue'].includes(m.status))
|
|
484
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
485
|
+
|
|
486
|
+
const unpaidCredit = unpaid.filter((m: any) => m.direction === 'credit')
|
|
487
|
+
const unpaidDebit = unpaid.filter((m: any) => m.direction === 'debit')
|
|
488
|
+
const overdueCredit = unpaidCredit.filter((m: any) => m.status === 'overdue' || m.due_date < today)
|
|
489
|
+
const overdueDebit = unpaidDebit.filter((m: any) => m.status === 'overdue' || m.due_date < today)
|
|
490
|
+
|
|
491
|
+
const monthStart = today.slice(0, 7) + '-01'
|
|
492
|
+
const monthPaid = allMovs.filter((m: any) => m.status === 'paid' && m.payment_date >= monthStart)
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
totalBalance,
|
|
496
|
+
totalReceivable: unpaidCredit.reduce((s: number, m: any) => s + m.amount - m.paid_amount, 0),
|
|
497
|
+
totalPayable: unpaidDebit.reduce((s: number, m: any) => s + m.amount - m.paid_amount, 0),
|
|
498
|
+
monthlyInflow: monthPaid.filter((m: any) => m.direction === 'credit').reduce((s: number, m: any) => s + m.paid_amount, 0),
|
|
499
|
+
monthlyOutflow: monthPaid.filter((m: any) => m.direction === 'debit').reduce((s: number, m: any) => s + m.paid_amount, 0),
|
|
500
|
+
overdueReceivableCount: overdueCredit.length,
|
|
501
|
+
overdueReceivableAmount: overdueCredit.reduce((s: number, m: any) => s + m.amount - m.paid_amount, 0),
|
|
502
|
+
overduePayableCount: overdueDebit.length,
|
|
503
|
+
overduePayableAmount: overdueDebit.reduce((s: number, m: any) => s + m.amount - m.paid_amount, 0),
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return provider
|
|
509
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Invoice, InvoiceItem, FinancialMovement, BankAccount,
|
|
3
|
+
CashSession, PaymentMethod, PaymentMethodType, ChartOfAccountsNode,
|
|
4
|
+
CardTransaction,
|
|
5
|
+
CreateInvoiceInput, PayMovementInput,
|
|
6
|
+
OpenCashSessionInput, CloseCashSessionInput, CreateBankAccountInput,
|
|
7
|
+
InvoiceQuery, MovementQuery, StatementQuery,
|
|
8
|
+
PaginatedResult, FinancialSummary, StatementEntry, CashSessionSummary,
|
|
9
|
+
DateRange,
|
|
10
|
+
} from '../types'
|
|
11
|
+
|
|
12
|
+
export interface FinancialDataProvider {
|
|
13
|
+
// --- Invoices ---
|
|
14
|
+
getInvoices(query: InvoiceQuery): Promise<PaginatedResult<Invoice>>
|
|
15
|
+
getInvoiceById(id: string): Promise<Invoice | null>
|
|
16
|
+
getInvoiceItems(invoiceId: string): Promise<InvoiceItem[]>
|
|
17
|
+
createInvoice(input: CreateInvoiceInput): Promise<Invoice>
|
|
18
|
+
updateInvoice(id: string, data: Partial<Invoice>): Promise<Invoice>
|
|
19
|
+
cancelInvoice(id: string): Promise<void>
|
|
20
|
+
|
|
21
|
+
// --- Movements ---
|
|
22
|
+
getMovements(query: MovementQuery): Promise<PaginatedResult<FinancialMovement>>
|
|
23
|
+
payMovement(input: PayMovementInput): Promise<FinancialMovement>
|
|
24
|
+
cancelMovement(id: string): Promise<void>
|
|
25
|
+
|
|
26
|
+
// --- Bank Accounts ---
|
|
27
|
+
getBankAccounts(): Promise<BankAccount[]>
|
|
28
|
+
createBankAccount(data: CreateBankAccountInput): Promise<BankAccount>
|
|
29
|
+
updateBankAccount(id: string, data: Partial<BankAccount>): Promise<BankAccount>
|
|
30
|
+
|
|
31
|
+
// --- Cash Sessions ---
|
|
32
|
+
getCashSessions(bankAccountId?: string): Promise<CashSession[]>
|
|
33
|
+
getOpenSession(bankAccountId: string): Promise<CashSession | null>
|
|
34
|
+
openCashSession(input: OpenCashSessionInput): Promise<CashSession>
|
|
35
|
+
closeCashSession(input: CloseCashSessionInput): Promise<CashSession>
|
|
36
|
+
getCashSessionSummary(sessionId: string): Promise<CashSessionSummary>
|
|
37
|
+
|
|
38
|
+
// --- Payment Methods ---
|
|
39
|
+
getPaymentMethods(): Promise<PaymentMethod[]>
|
|
40
|
+
getPaymentMethodTypes(): Promise<PaymentMethodType[]>
|
|
41
|
+
|
|
42
|
+
// --- Chart of Accounts ---
|
|
43
|
+
getChartOfAccounts(): Promise<ChartOfAccountsNode[]>
|
|
44
|
+
|
|
45
|
+
// --- Card Transactions ---
|
|
46
|
+
getCardTransactions(dateRange?: DateRange): Promise<CardTransaction[]>
|
|
47
|
+
|
|
48
|
+
// --- Statements ---
|
|
49
|
+
getStatement(query: StatementQuery): Promise<StatementEntry[]>
|
|
50
|
+
|
|
51
|
+
// --- Summary / Dashboard ---
|
|
52
|
+
getSummary(dateRange?: DateRange): Promise<FinancialSummary>
|
|
53
|
+
}
|