diff --git a/package.json b/package.json index b46ce58..0756e3e 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.39.0", "clsx": "^2.1.0", + "date-fns": "^4.1.0", "framer-motion": "^12.34.3", "lucide-react": "^0.575.0", "next": "16.1.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c36388c..7e01ab3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: clsx: specifier: ^2.1.0 version: 2.1.1 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 framer-motion: specifier: ^12.34.3 version: 12.34.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -1208,6 +1211,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -3677,6 +3683,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns@4.1.0: {} + debug@3.2.7: dependencies: ms: 2.1.3 diff --git a/src/app/(app)/accounts/page.tsx b/src/app/(app)/accounts/page.tsx index 358ebe0..61898fc 100644 --- a/src/app/(app)/accounts/page.tsx +++ b/src/app/(app)/accounts/page.tsx @@ -60,22 +60,6 @@ const MOCK_GIRO = [ }, ]; -const MOCK_CREDIT = [ - { - id: "4", - name: "Cartão Platinum", - institution: "Nubank", - category: "credit" as const, - balance: 0, - creditLimit: 12000, - creditUsed: 3450.9, - creditClosingDays: 4, - colorHex: "#8A05BE", - lastSyncedAt: new Date(Date.now() - 86400000 * 2), // 2 days ago - members: [{ id: "u1", name: "Você", role: "owner" as const }], - }, -]; - const MOCK_VAULT = [ { id: "5", @@ -156,8 +140,6 @@ export default function AccountsPage() { // eslint-disable-next-line @typescript-eslint/no-explicit-any const [giroAccounts, setGiroAccounts] = useState([]); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const [creditAccounts, setCreditAccounts] = useState([]); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const [vaultAccounts, setVaultAccounts] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isSeeding, setIsSeeding] = useState(false); @@ -176,11 +158,9 @@ export default function AccountsPage() { colorHex: account.color_hex || "#8A05BE", lastSyncedAt: new Date(account.updated_at), members: [{ id: "u1", name: "Você", role: "owner" as const }], - creditUsed: account.category === "credit" ? 0 : undefined, })); setGiroAccounts(mapped.filter((a) => ["checking", "wallet"].includes(a.category))); - setCreditAccounts(mapped.filter((a) => ["credit"].includes(a.category))); setVaultAccounts(mapped.filter((a) => ["savings", "vault"].includes(a.category))); } if (isMounted) setIsLoading(false); @@ -192,8 +172,7 @@ export default function AccountsPage() { }, []); const totalGiro = giroAccounts.reduce((acc, curr) => acc + curr.balance, 0); - const totalCreditBills = creditAccounts.reduce((acc, curr) => acc + (curr.creditUsed || 0), 0); - const realLiquidity = totalGiro - totalCreditBills; + const realLiquidity = totalGiro; const totalReserves = vaultAccounts.reduce((acc, curr) => acc + curr.balance, 0); @@ -227,7 +206,7 @@ export default function AccountsPage() { e.preventDefault(); setDragOverId(null); if (draggedAccountId && draggedAccountId !== targetId) { - const tempAll = [...giroAccounts, ...creditAccounts, ...vaultAccounts]; + const tempAll = [...giroAccounts, ...vaultAccounts]; const targetAccount = tempAll.find((a) => a.id === targetId); if (targetAccount?.category === "credit") { @@ -244,7 +223,7 @@ export default function AccountsPage() { }; // Combine all mock data to find specific accounts for the transfer modal - const ALL_ACCOUNTS = [...giroAccounts, ...creditAccounts, ...vaultAccounts]; + const ALL_ACCOUNTS = [...giroAccounts, ...vaultAccounts]; const handleTransferValueChange = (e: React.ChangeEvent) => { const value = e.target.value.replace(/\D/g, ""); @@ -268,7 +247,6 @@ export default function AccountsPage() { const updateBalance = (id: string, amount: number) => { setGiroAccounts((prev) => prev.map((a) => (a.id === id ? { ...a, balance: a.balance + amount } : a))); - setCreditAccounts((prev) => prev.map((a) => (a.id === id ? { ...a, balance: a.balance + amount } : a))); setVaultAccounts((prev) => prev.map((a) => (a.id === id ? { ...a, balance: a.balance + amount } : a))); }; @@ -287,7 +265,6 @@ export default function AccountsPage() { const mapUpdate = (prev: any[]) => prev.map((a) => (a.id === id ? { ...a, ...updates, colorHex: updates.color_hex || a.colorHex } : a)); setGiroAccounts(mapUpdate); - setCreditAccounts(mapUpdate); setVaultAccounts(mapUpdate); } }; @@ -296,7 +273,6 @@ export default function AccountsPage() { const res = await deleteAccountAction(id); if (res.success) { setGiroAccounts((prev) => prev.filter((a) => a.id !== id)); - setCreditAccounts((prev) => prev.filter((a) => a.id !== id)); setVaultAccounts((prev) => prev.filter((a) => a.id !== id)); } }; @@ -310,6 +286,7 @@ export default function AccountsPage() { balance: newAccount.balance || 0, color_hex: newAccount.colorHex, }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any const res = await createAccountAction(accountInsert as any); if (res.success && res.data) { @@ -324,8 +301,6 @@ export default function AccountsPage() { dbAccount.category === "wallet" ) { setGiroAccounts((prev) => [...prev, dbAccount]); - } else if (dbAccount.category === "credit") { - setCreditAccounts((prev) => [...prev, dbAccount]); } else if (dbAccount.category === "vault") { setVaultAccounts((prev) => [...prev, dbAccount]); } @@ -334,7 +309,7 @@ export default function AccountsPage() { const handleSeedData = async () => { setIsSeeding(true); - const defaults = [...MOCK_GIRO, ...MOCK_CREDIT, ...MOCK_VAULT]; + const defaults = [...MOCK_GIRO, ...MOCK_VAULT]; for (const acc of defaults) { await createAccountAction({ name: acc.name, @@ -342,6 +317,7 @@ export default function AccountsPage() { category: acc.category, balance: acc.balance || 0, color_hex: acc.colorHex, + // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); } window.location.reload(); @@ -364,21 +340,17 @@ export default function AccountsPage() { subtitle="Liquidez Imediata" className="mb-16" title={{formatCurrency(realLiquidity)}} - badge="Livre de faturas fechadas" action={
- {!isLoading && - giroAccounts.length === 0 && - creditAccounts.length === 0 && - vaultAccounts.length === 0 && ( - - )} + {!isLoading && giroAccounts.length === 0 && vaultAccounts.length === 0 && ( + + )} +
+ + {isLoading ? ( +
+
+
+ ) : cards.length === 0 ? ( +
+
+ +
+

Nenhum cartão cadastrado

+

+ Adicione seu primeiro cartão de crédito para acompanhar as faturas e projetar o seu fluxo de + caixa no futuro. +

+ +
+ ) : ( +
+ {cards.map((card) => { + const cardInvoices = invoices.filter((inv) => inv.credit_card_id === card.id); + + // Find current open invoice + const currentInvoice = cardInvoices.find((inv) => inv.status === "pending"); + + // Calculate utilized limit: sum of all pending invoices + const usedLimit = cardInvoices + .filter((inv) => inv.status === "pending") + .reduce((acc, inv) => acc + inv.amount, 0); + + // Remaining limit + const remainingLimit = Math.max(0, card.limit_amount - usedLimit); + + return ( + +
+
+
+ +
+
+

{card.name}

+

+ Conta vinculada:{" "} + + {card.account?.name || "Desconhecida"} + +

+
+
+ +
+ +
+
+
+ + Fatura Atual + + + R${" "} + {currentInvoice + ? currentInvoice.amount.toLocaleString("pt-BR", { + minimumFractionDigits: 2, + }) + : "0,00"} + +
+
+ + Disponível (de R${" "} + {card.limit_amount.toLocaleString("pt-BR", { + minimumFractionDigits: 2, + })} + ) + + 0 + ? "text-sm font-semibold text-emerald-500" + : "text-sm font-semibold text-red-500" + } + > + R${" "} + {remainingLimit.toLocaleString("pt-BR", { + minimumFractionDigits: 2, + })} + +
+
+ + {/* Progress Bar for Limit */} +
+
= card.limit_amount ? "bg-red-500" : "bg-emerald-500"}`} + style={{ + width: `${Math.min(100, (usedLimit / card.limit_amount) * 100)}%`, + }} + >
+
+ +
+
+ Fechamento + + Dia {card.closing_day} + +
+
+ Vencimento + + Dia {card.due_day} + +
+
+
+ +
+ {currentInvoice ? ( + + ) : ( + + )} +
+
+ ); + })} +
+ )} + + + + ); +} diff --git a/src/app/(app)/transactions/page.tsx b/src/app/(app)/transactions/page.tsx new file mode 100644 index 0000000..31d6241 --- /dev/null +++ b/src/app/(app)/transactions/page.tsx @@ -0,0 +1,362 @@ +"use client"; + +import React, { useState, useEffect, useMemo } from "react"; +import { Plus } from "lucide-react"; +import { motion, useScroll, useTransform } from "framer-motion"; +import { TransactionsHeader } from "@/components/ui/TransactionsHeader"; +import { TransactionListGroup } from "@/components/ui/TransactionListGroup"; +import { TransactionItem } from "@/components/ui/TransactionItem"; +import { CreateTransactionModal } from "@/components/ui/CreateTransactionModal"; +import { + fetchTransactions, + createTransactionAction, + updateTransactionAction, + deleteTransactionAction, + fetchCategories, + TransactionInsert, +} from "@/app/actions/transactionActions"; +import { fetchAccounts } from "@/app/actions/accountActions"; +import { fetchCreditCards } from "@/app/actions/creditCardActions"; + +export default function TransactionsPage() { + // State + const [searchQuery, setSearchQuery] = useState(""); + const [filterType, setFilterType] = useState("all"); + const [filterStatus, setFilterStatus] = useState("all"); + const [filterAccountId, setFilterAccountId] = useState("all"); + const [filterCategoryId, setFilterCategoryId] = useState("all"); + const [sortBy, setSortBy] = useState<"date_desc" | "date_asc" | "amount_desc" | "amount_asc">("date_asc"); + const [filterCurrentMonth, setFilterCurrentMonth] = useState(true); + + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [editingTransaction, setEditingTransaction] = useState(null); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [transactions, setTransactions] = useState([]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [accounts, setAccounts] = useState([]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [categories, setCategories] = useState([]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [creditCards, setCreditCards] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + async function loadData() { + const [txs, accs, cats, ccs] = await Promise.all([ + fetchTransactions(), + fetchAccounts(), + fetchCategories(), + fetchCreditCards(), + ]); + setTransactions(txs || []); + setAccounts(accs || []); + setCategories(cats || []); + setCreditCards(ccs || []); + setIsLoading(false); + } + loadData(); + }, []); + + // Derived state + const filteredTransactions = useMemo(() => { + let result = transactions; + + if (filterCurrentMonth) { + const now = new Date(); + const currentMonthStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; + result = result.filter((t) => t.transaction_date.startsWith(currentMonthStr)); + } + + if (searchQuery) { + const query = searchQuery.toLowerCase(); + result = result.filter( + (t) => t.description.toLowerCase().includes(query) || t.category?.name?.toLowerCase().includes(query), + ); + } + + if (filterType !== "all") { + result = result.filter((t) => t.type === filterType); + } + + if (filterStatus !== "all") { + result = result.filter((t) => t.status === filterStatus); + } + + if (filterAccountId !== "all") { + // Include credit cards as 'accounts' conceptually for filtering + result = result.filter( + (t) => + t.account_id === filterAccountId || + t.credit_card_id === filterAccountId || + t.destination_account_id === filterAccountId, + ); + } + + if (filterCategoryId !== "all") { + result = result.filter((t) => t.category_id === filterCategoryId); + } + + return result; + }, [transactions, searchQuery, filterType, filterStatus, filterAccountId, filterCategoryId, filterCurrentMonth]); + + const groupedTransactions = useMemo(() => { + if (sortBy === "amount_desc" || sortBy === "amount_asc") { + // Flat list, no date grouping, sorted by absolute amount + const sorted = [...filteredTransactions].sort((a, b) => { + const amountA = Math.abs(a.amount); + const amountB = Math.abs(b.amount); + return sortBy === "amount_desc" ? amountB - amountA : amountA - amountB; + }); + return [{ date: "Todas as transações", items: sorted }]; + } + + // Date grouping + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const groups: Record = {}; + filteredTransactions.forEach((t) => { + const dateStr = t.transaction_date; + if (!groups[dateStr]) groups[dateStr] = []; + groups[dateStr].push(t); + }); + + const sortedDates = Object.keys(groups).sort((a, b) => + sortBy === "date_desc" ? b.localeCompare(a) : a.localeCompare(b), + ); + + return sortedDates.map((date) => ({ + date, + items: groups[date], + })); + }, [filteredTransactions, sortBy]); + + const totalAmount = useMemo(() => { + return filteredTransactions.reduce((acc, curr) => { + if (curr.type === "income") return acc + curr.amount; + if (curr.type === "expense") return acc - curr.amount; + return acc; // Transfer and adjustment don't affect this naive total directly + }, 0); + }, [filteredTransactions]); + + const handleSaveTransaction = async ( + newTx: Omit, + installments: number = 1, + id?: string, + ) => { + let res; + if (id) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { is_recurring, ...updates } = newTx; // don't update recurring status easily on single items yet + res = await updateTransactionAction(id, updates); + } else { + res = await createTransactionAction(newTx, installments); + } + + if (res.success) { + const txs = await fetchTransactions(); + setTransactions(txs || []); + } else { + alert("Erro ao salvar transação: " + res.error); + } + }; + + const handleDeleteTransaction = async (id: string, isGroup: boolean) => { + const msg = isGroup + ? "Esta transação faz parte de um parcelamento ou recorrência. Deseja apagá-la junto de todas as parcelas/recorrências futuras?" + : "Tem certeza que deseja apagar esta transação?"; + + if (!confirm(msg)) return; + + const res = await deleteTransactionAction(id); + if (res.success) { + setIsCreateModalOpen(false); + setEditingTransaction(null); + const txs = await fetchTransactions(); + setTransactions(txs || []); + } else { + alert("Erro ao excluir: " + res.error); + } + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const openEditModal = (tx: any) => { + setEditingTransaction(tx); + setIsCreateModalOpen(true); + }; + + const closeEditModal = () => { + setIsCreateModalOpen(false); + setEditingTransaction(null); + }; + + // Parallax + const { scrollY } = useScroll(); + const yBg1 = useTransform(scrollY, [0, 1000], [0, 400]); + const yBg2 = useTransform(scrollY, [0, 1000], [0, -400]); + + return ( +
+ + + + + + {/* Filter Bar */} +
+
+ + +
+ + + + + + + + + +
+ + +
+
+ + {isLoading ? ( +
+
+
+ ) : groupedTransactions.length === 0 ? ( +
+

Nenhuma transação encontrada

+

Tente ajustar seus filtros ou cadastre algo novo.

+
+ ) : ( +
+ {groupedTransactions.map((group) => ( + + {group.items.map((tx) => ( + openEditModal(tx)} + /> + ))} + + ))} +
+ )} + + {/* Fab Button for Mobile & Desktop context */} +
+ +
+ + +
+ ); +} diff --git a/src/app/actions/accountActions.ts b/src/app/actions/accountActions.ts index 3ee109a..9cbe141 100644 --- a/src/app/actions/accountActions.ts +++ b/src/app/actions/accountActions.ts @@ -30,6 +30,21 @@ export async function createAccountAction(account: AccountInsert) { return { success: false, error: "Usuário não autenticado." }; } + // Enforce 1-to-1 institution limit + const { data: existingAccounts, error: existingError } = await supabase + .from("accounts") + .select("id") + .eq("institution", account.institution || "") + .limit(1); + + if (existingError) { + return { success: false, error: "Erro ao validar instituição." }; + } + + if (existingAccounts && existingAccounts.length > 0) { + return { success: false, error: "Você já possui uma conta ativa nesta instituição." }; + } + const id = account.id || randomUUID(); const { error } = await supabase.from("accounts").insert({ ...account, id }); diff --git a/src/app/actions/creditCardActions.ts b/src/app/actions/creditCardActions.ts new file mode 100644 index 0000000..d87b0b0 --- /dev/null +++ b/src/app/actions/creditCardActions.ts @@ -0,0 +1,71 @@ +"use server"; + +import { createClient } from "@/lib/supabase/server"; +import { revalidatePath } from "next/cache"; +import { Database } from "@/types/supabase"; + +export type CreditCardRow = Database["public"]["Tables"]["credit_cards"]["Row"]; +export type CreditCardInsert = Database["public"]["Tables"]["credit_cards"]["Insert"]; +export type CreditCardUpdate = Database["public"]["Tables"]["credit_cards"]["Update"]; + +export async function fetchCreditCards() { + const supabase = await createClient(); + const { data, error } = await supabase + .from("credit_cards") + .select( + ` + *, + account:accounts(*) + `, + ) + .order("name", { ascending: true }); + + if (error) { + console.error("Error fetching credit cards:", error); + return []; + } + + return data; +} + +export async function createCreditCard(creditCard: CreditCardInsert) { + const supabase = await createClient(); + const { error } = await supabase.from("credit_cards").insert(creditCard); + + if (error) { + console.error("Error creating credit card:", error); + return { success: false, error: error.message }; + } + + revalidatePath("/credit-cards"); + revalidatePath("/accounts"); // In case it affects global balance/accounts flow + return { success: true }; +} + +export async function updateCreditCard(id: string, updates: CreditCardUpdate) { + const supabase = await createClient(); + const { error } = await supabase.from("credit_cards").update(updates).eq("id", id); + + if (error) { + console.error("Error updating credit card:", error); + return { success: false, error: error.message }; + } + + revalidatePath("/credit-cards"); + revalidatePath("/accounts"); + return { success: true }; +} + +export async function deleteCreditCard(id: string) { + const supabase = await createClient(); + const { error } = await supabase.from("credit_cards").delete().eq("id", id); + + if (error) { + console.error("Error deleting credit card:", error); + return { success: false, error: error.message }; + } + + revalidatePath("/credit-cards"); + revalidatePath("/accounts"); + return { success: true }; +} diff --git a/src/app/actions/invoiceActions.ts b/src/app/actions/invoiceActions.ts new file mode 100644 index 0000000..9e19450 --- /dev/null +++ b/src/app/actions/invoiceActions.ts @@ -0,0 +1,75 @@ +"use server"; + +import { createClient } from "@/lib/supabase/server"; +import { revalidatePath } from "next/cache"; +import { Database } from "@/types/supabase"; + +export type InvoiceRow = Database["public"]["Tables"]["invoices"]["Row"]; + +export async function fetchInvoices(creditCardId?: string) { + const supabase = await createClient(); + + let query = supabase + .from("invoices") + .select( + ` + *, + credit_card:credit_cards(*) + `, + ) + .order("reference_month", { ascending: true }); + + if (creditCardId) { + query = query.eq("credit_card_id", creditCardId); + } + + const { data, error } = await query; + + if (error) { + console.error("Error fetching invoices:", error); + return []; + } + + return data; +} + +export async function payInvoice(invoiceId: string) { + const supabase = await createClient(); + + // First, get the invoice to find the internal system_transaction_id + const { data: invoice, error: fetchError } = await supabase + .from("invoices") + .select("*") + .eq("id", invoiceId) + .single(); + + if (fetchError || !invoice) { + console.error("Error finding invoice:", fetchError); + return { success: false, error: "Fatura não encontrada." }; + } + + // According to the architecture, paying an invoice means setting the shadow transaction status to 'paid' + // Let's also set the invoice status to 'paid' for clarity. + + const { error: txError } = await supabase + .from("transactions") + .update({ status: "paid" }) + .eq("id", invoice.system_transaction_id!); + + if (txError) { + console.error("Error paying system transaction:", txError); + return { success: false, error: txError.message }; + } + + const { error: invError } = await supabase.from("invoices").update({ status: "paid" }).eq("id", invoiceId); + + if (invError) { + console.error("Error updating invoice status:", invError); + return { success: false, error: invError.message }; + } + + revalidatePath("/credit-cards"); + revalidatePath("/accounts"); + revalidatePath("/transactions"); + return { success: true }; +} diff --git a/src/app/actions/transactionActions.ts b/src/app/actions/transactionActions.ts new file mode 100644 index 0000000..0fe708c --- /dev/null +++ b/src/app/actions/transactionActions.ts @@ -0,0 +1,166 @@ +"use server"; + +import { createClient } from "@/lib/supabase/server"; +import { revalidatePath } from "next/cache"; +import { Database } from "@/types/supabase"; +import { randomUUID } from "crypto"; + +export type TransactionInsert = Database["public"]["Tables"]["transactions"]["Insert"]; +export type TransactionUpdate = Database["public"]["Tables"]["transactions"]["Update"]; + +export async function fetchTransactions(searchQuery?: string) { + const supabase = await createClient(); + let query = supabase + .from("transactions") + .select( + ` + *, + category:categories(*), + account:accounts!transactions_account_id_fkey(*), + destination_account:accounts!transactions_destination_account_id_fkey(*), + credit_card:credit_cards(*) + `, + ) + .order("transaction_date", { ascending: false }) + .order("created_at", { ascending: false }); + + if (searchQuery) { + query = query.ilike("description", `%${searchQuery}%`); + } + + const { data, error } = await query; + + if (error) { + console.error("Error fetching transactions:", error); + return []; + } + + return data; +} + +import { addMonths, format } from "date-fns"; + +export async function createTransactionAction( + transaction: Omit, + installments: number = 1, +) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return { success: false, error: "Usuário não autenticado." }; + } + + if (installments > 1 && transaction.credit_card_id) { + // Handle installments by dividing the total amount correctly + const baseAmount = transaction.amount; + const installmentAmount = Math.floor((baseAmount / installments) * 100) / 100; + const remainder = Math.round((baseAmount - installmentAmount * installments) * 100) / 100; + + const transactionsToInsert: TransactionInsert[] = []; + const baseDate = new Date(transaction.transaction_date + "T12:00:00Z"); // Midday to avoid timezone offset issues + const groupId = randomUUID(); + + for (let i = 0; i < installments; i++) { + const currentInstallmentAmount = i === 0 ? installmentAmount + remainder : installmentAmount; + + const nextDate = addMonths(baseDate, i); + const formattedDate = format(nextDate, "yyyy-MM-dd"); + + transactionsToInsert.push({ + ...transaction, + id: randomUUID(), + user_id: user.id, + amount: currentInstallmentAmount, + transaction_date: formattedDate, + description: `${transaction.description} (${i + 1}/${installments})`, + group_id: groupId, + installment_current: i + 1, + installment_total: installments, + }); + } + + const { error } = await supabase.from("transactions").insert(transactionsToInsert); + + if (error) { + console.error("Error creating installment transactions:", error); + return { success: false, error: error.message }; + } + } else { + const id = transaction.id || randomUUID(); + const { error } = await supabase.from("transactions").insert({ ...transaction, id, user_id: user.id }); + + if (error) { + console.error("Error creating transaction:", error); + return { success: false, error: error.message }; + } + } + + revalidatePath("/transactions"); + revalidatePath("/credit-cards"); + revalidatePath("/accounts"); + return { success: true }; +} + +export async function updateTransactionAction(id: string, updates: TransactionUpdate) { + const supabase = await createClient(); + const { error } = await supabase.from("transactions").update(updates).eq("id", id); + + if (error) { + console.error("Error updating transaction:", error); + return { success: false, error: error.message }; + } + + revalidatePath("/transactions"); + return { success: true }; +} + +export async function deleteTransactionAction(id: string) { + const supabase = await createClient(); + + // Fetch transaction to determine if it has a group_id + const { data: tx, error: fetchError } = await supabase.from("transactions").select("*").eq("id", id).single(); + + if (fetchError || !tx) { + return { success: false, error: "Transação não encontrada." }; + } + + let deleteError; + + if (tx.group_id) { + // Delete this and all future installments + const { error } = await supabase + .from("transactions") + .delete() + .eq("group_id", tx.group_id) + .gte("transaction_date", tx.transaction_date); + deleteError = error; + } else { + const { error } = await supabase.from("transactions").delete().eq("id", id); + deleteError = error; + } + + if (deleteError) { + console.error("Error deleting transaction:", deleteError); + return { success: false, error: deleteError.message }; + } + + revalidatePath("/transactions"); + revalidatePath("/credit-cards"); + revalidatePath("/accounts"); + return { success: true }; +} + +export async function fetchCategories() { + const supabase = await createClient(); + const { data, error } = await supabase.from("categories").select("*").order("name", { ascending: true }); + + if (error) { + console.error("Error fetching categories:", error); + return []; + } + + return data; +} diff --git a/src/components/ui/CreateAccountModal.tsx b/src/components/ui/CreateAccountModal.tsx index f3c7cb1..0d0f3d2 100644 --- a/src/components/ui/CreateAccountModal.tsx +++ b/src/components/ui/CreateAccountModal.tsx @@ -15,7 +15,6 @@ const CATEGORIES = [ { value: "savings", label: "Conta Poupança" }, { value: "wallet", label: "Carteira (Dinheiro)" }, { value: "vault", label: "Cofre / Investimento" }, - { value: "credit", label: "Cartão de Crédito" }, ]; const COLORS = [ @@ -30,17 +29,12 @@ const COLORS = [ ]; export function CreateAccountModal({ isOpen, onClose, onCreate }: CreateAccountModalProps) { - const [name, setName] = useState(""); const [institution, setInstitution] = useState(""); - const [category, setCategory] = useState<"checking" | "savings" | "wallet" | "vault" | "credit">("checking"); + const [category, setCategory] = useState<"checking" | "savings" | "wallet" | "vault">("checking"); const [colorHex, setColorHex] = useState(COLORS[0]); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [initialBalance, setInitialBalance] = useState(""); - // Credit specific fields - const [creditLimit, setCreditLimit] = useState(""); - const [creditClosingDays, setCreditClosingDays] = useState("5"); - if (!isOpen) return null; const handleBalanceChange = (e: React.ChangeEvent, setter: (val: string) => void) => { @@ -63,22 +57,17 @@ export function CreateAccountModal({ isOpen, onClose, onCreate }: CreateAccountM const parseValue = (val: string) => parseFloat(val.replace(/\./g, "").replace(",", ".")) || 0; const balanceNum = parseValue(initialBalance); - const creditLimitNum = category === "credit" ? parseValue(creditLimit) : undefined; - const closingDaysNum = category === "credit" ? parseInt(creditClosingDays) || 0 : undefined; const newAccount = { id: Math.random().toString(36).substring(7), - name, + name: institution, // Name now mirrors institution automatically institution, category, colorHex, - balance: category === "credit" ? 0 : balanceNum, // Balance is 0 for credit card, "balance" here acts as total limit maybe, but we use creditUsed instead - creditLimit: creditLimitNum, - creditUsed: category === "credit" ? 0 : undefined, - creditClosingDays: closingDaysNum, + balance: balanceNum, lastSyncedAt: new Date(), members: [{ id: "u1", name: "Você", role: "owner" }], - initialTransactionAmount: category !== "credit" ? balanceNum : 0, // Useful for creating the initial transaction + initialTransactionAmount: balanceNum, // Useful for creating the initial transaction }; onCreate(newAccount); @@ -99,29 +88,16 @@ export function CreateAccountModal({ isOpen, onClose, onCreate }: CreateAccountM
-
-
- - setName(e.target.value)} - placeholder="ex: Conta Nu" - className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-zinc-100 placeholder:text-zinc-700 outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/50 transition-all" - /> -
-
- - setInstitution(e.target.value)} - placeholder="ex: Nubank" - className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-zinc-100 placeholder:text-zinc-700 outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/50 transition-all" - /> -
+
+ + setInstitution(e.target.value)} + placeholder="ex: Nubank" + className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-zinc-100 placeholder:text-zinc-700 outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/50 transition-all" + />
@@ -197,60 +173,26 @@ export function CreateAccountModal({ isOpen, onClose, onCreate }: CreateAccountM
)}
- - {category === "credit" ? ( -
-
- -
- R$ - handleBalanceChange(e, setCreditLimit)} - placeholder="0,00" - className="w-full bg-zinc-950 border border-zinc-800 rounded-xl pl-10 pr-4 py-3 text-zinc-100 outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/50 transition-all" - /> -
-
-
- - setCreditClosingDays(e.target.value)} - placeholder="ex: 5" - className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-zinc-100 outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/50 transition-all" - /> -
-
- ) : ( -
- -
- R$ - handleBalanceChange(e, setInitialBalance)} - placeholder="0,00" - className="w-full bg-zinc-950 border border-zinc-800 rounded-xl pl-10 pr-4 py-3 text-zinc-100 outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/50 transition-all" - /> -
-

Isso criará uma transação de lançamento inicial.

+
+ +
+ R$ + handleBalanceChange(e, setInitialBalance)} + placeholder="0,00" + className="w-full bg-zinc-950 border border-zinc-800 rounded-xl pl-10 pr-4 py-3 text-zinc-100 outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/50 transition-all" + />
- )} +

Isso criará uma transação de lançamento inicial.

+
+ )} + +
+
+ + +
+ + setName(e.target.value)} + placeholder="Nome" + className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-zinc-100 outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition-all" + /> +
+ +
+ +
+ R$ + +
+
+ +
+ + +
+ +
+
+ + setClosingDay(e.target.value)} + placeholder="ex: 25" + className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-zinc-100 outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition-all" + /> +
+
+ + setDueDay(e.target.value)} + placeholder="ex: 5" + className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-zinc-100 outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition-all" + /> +
+
+ +
+ +
+
+ + + ); +} diff --git a/src/components/ui/CreateTransactionModal.tsx b/src/components/ui/CreateTransactionModal.tsx new file mode 100644 index 0000000..54e6a59 --- /dev/null +++ b/src/components/ui/CreateTransactionModal.tsx @@ -0,0 +1,485 @@ +/* eslint-disable react-hooks/set-state-in-effect */ +import React, { useState, useEffect } from "react"; +import { X, ArrowDown, ArrowUp, ArrowRightLeft, Settings2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { TransactionInsert } from "@/app/actions/transactionActions"; + +interface CreateTransactionModalProps { + isOpen: boolean; + onClose: () => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + accounts: any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + categories: any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + creditCards?: any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transactionToEdit?: any | null; + onSave: (transaction: Omit, installments?: number, id?: string) => void; + onDelete?: (id: string, isGroup: boolean) => void; +} + +const TYPES = [ + { id: "income" as const, label: "Receita", icon: ArrowUp, color: "text-emerald-500", bg: "bg-emerald-500/10" }, + { id: "expense" as const, label: "Despesa", icon: ArrowDown, color: "text-red-500", bg: "bg-red-500/10" }, + { + id: "transfer" as const, + label: "Transferência", + icon: ArrowRightLeft, + color: "text-blue-500", + bg: "bg-blue-500/10", + }, + { id: "adjustment" as const, label: "Ajuste", icon: Settings2, color: "text-zinc-400", bg: "bg-zinc-800" }, +]; + +export function CreateTransactionModal({ + isOpen, + onClose, + accounts, + categories, + creditCards = [], + transactionToEdit, + onSave, + onDelete, +}: CreateTransactionModalProps) { + const [type, setType] = useState<"income" | "expense" | "transfer" | "adjustment">("expense"); + const [amount, setAmount] = useState(""); + const [description, setDescription] = useState(""); + const [date, setDate] = useState(new Date().toISOString().split("T")[0]); + + const [categoryId, setCategoryId] = useState(""); + const [accountId, setAccountId] = useState(""); + const [destinationAccountId, setDestinationAccountId] = useState(""); + + const [isCreditCard, setIsCreditCard] = useState(false); + const [creditCardId, setCreditCardId] = useState(""); + const [installments, setInstallments] = useState("1"); + + const [isRecurring, setIsRecurring] = useState(false); + + useEffect(() => { + if (isOpen) { + if (transactionToEdit) { + setType(transactionToEdit.type); + setAmount( + new Intl.NumberFormat("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(transactionToEdit.amount), + ); + setDescription(transactionToEdit.description); + setDate(transactionToEdit.transaction_date); + setCategoryId(transactionToEdit.category_id || ""); + setAccountId(transactionToEdit.account_id || ""); + setDestinationAccountId(transactionToEdit.destination_account_id || ""); + + if (transactionToEdit.credit_card_id) { + setIsCreditCard(true); + setCreditCardId(transactionToEdit.credit_card_id); + } else { + setIsCreditCard(false); + setCreditCardId(""); + } + + setInstallments("1"); + setIsRecurring(transactionToEdit.is_recurring || false); + } else { + setType("expense"); + setAmount(""); + setDescription(""); + setDate(new Date().toISOString().split("T")[0]); + setCategoryId(""); + setAccountId(""); + setDestinationAccountId(""); + setIsCreditCard(false); + setCreditCardId(""); + setInstallments("1"); + setIsRecurring(false); + } + } + }, [isOpen, transactionToEdit]); + + if (!isOpen) return null; + + const handleAmountChange = (e: React.ChangeEvent) => { + const value = e.target.value.replace(/\D/g, ""); + if (!value) { + setAmount(""); + return; + } + const numericValue = parseInt(value, 10) / 100; + const formatted = new Intl.NumberFormat("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(numericValue); + setAmount(formatted); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const numericAmount = parseFloat(amount.replace(/\./g, "").replace(",", ".")) || 0; + + const transaction: Omit = { + type, + amount: numericAmount, + description, + transaction_date: date, + account_id: accountId, + status: "paid", // Simplify for now + is_recurring: isRecurring, + }; + + if (type === "transfer") { + transaction.destination_account_id = destinationAccountId; + } else if (type === "adjustment") { + // Find adjustment category + const adjCat = categories.find((c) => c.is_system && c.name === "Ajuste de Saldo"); + if (adjCat) transaction.category_id = adjCat.id; + } else { + transaction.category_id = categoryId || null; + if (type === "expense" && isCreditCard && creditCardId) { + transaction.credit_card_id = creditCardId; + // Set the account_id to the credit card's linked account + const cc = creditCards.find((c) => c.id === creditCardId); + if (cc) transaction.account_id = cc.account_id; + } + } + + const parsedInstallments = isCreditCard && !transactionToEdit ? parseInt(installments, 10) || 1 : 1; + + onSave(transaction, parsedInstallments, transactionToEdit?.id); + onClose(); + }; + + const isTransfer = type === "transfer"; + const isAdjustment = type === "adjustment"; + const isEditing = !!transactionToEdit; + const isCreditCardDisabled = isEditing && !!transactionToEdit?.credit_card_id; + + return ( +
+
+
+

+ {isEditing ? "Editar Transação" : "Nova Transação"} +

+
+ {isEditing && onDelete && ( + + )} + +
+
+ +
+ {/* Types */} +
+ {TYPES.map((t) => { + const Icon = t.icon; + const isActive = type === t.id; + return ( + + ); + })} +
+ + {/* Amount */} +
+ +
+ R$ +
+ {/* Invisible span to dictate the dynamic width of the container */} + + {amount || "0,00"} + + +
+
+
+ + {/* Fields Grid */} +
+
+ + setDescription(e.target.value)} + placeholder="ex: Mercado Livre" + className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-zinc-100 outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/50 transition-all" + /> +
+
+ + setDate(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-zinc-100 outline-none focus:border-primary/50 transition-all [color-scheme:dark]" + /> +
+ + {/* Account Origin */} +
+ {type === "expense" ? ( +
+ +
+ + +
+
+ ) : ( + + )} + + {!isCreditCard || type !== "expense" ? ( + + ) : ( + + )} +
+ + {/* Account Destination (Transfer) */} + {isTransfer && ( +
+ + +
+ )} + + {/* Category (Income/Expense) */} + {!isTransfer && !isAdjustment && ( +
+ + +
+ )} + + {type === "expense" && isCreditCard && !isEditing && ( +
+ + setInstallments(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-zinc-100 outline-none focus:border-emerald-500/50 transition-all" + /> +
+ )} +
+ + {!isTransfer && !isAdjustment && ( +
+ setIsRecurring(e.target.checked)} + className="w-5 h-5 rounded border-zinc-700 text-primary focus:ring-primary/50 bg-zinc-900" + /> +
+ +

Ajuda a formar a Bússola Temporal

+
+
+ )} + +
+ +
+
+
+
+ ); +} diff --git a/src/components/ui/Sidebar.tsx b/src/components/ui/Sidebar.tsx index 08a0a9a..6cb85ed 100644 --- a/src/components/ui/Sidebar.tsx +++ b/src/components/ui/Sidebar.tsx @@ -5,11 +5,13 @@ import { usePathname } from "next/navigation"; import Link from "next/link"; import { motion } from "framer-motion"; import { twMerge } from "tailwind-merge"; -import { LayoutDashboard, Wallet, Receipt, TrendingUp, Settings } from "lucide-react"; +import { LayoutDashboard, Wallet, Receipt, TrendingUp, Settings, ArrowRightLeft, CreditCard } from "lucide-react"; const MENU_ITEMS = [ { label: "Dashboard", href: "/dashboard", icon: LayoutDashboard }, { label: "Contas", href: "/accounts", icon: Wallet }, + { label: "Transações", href: "/transactions", icon: ArrowRightLeft }, + { label: "Cartões", href: "/credit-cards", icon: CreditCard }, { label: "Faturas", href: "/invoices", icon: Receipt }, { label: "Investimentos", href: "/investments", icon: TrendingUp }, { label: "Configurações", href: "/settings", icon: Settings }, diff --git a/src/components/ui/TransactionItem.tsx b/src/components/ui/TransactionItem.tsx new file mode 100644 index 0000000..2d5885a --- /dev/null +++ b/src/components/ui/TransactionItem.tsx @@ -0,0 +1,188 @@ +import React from "react"; +import { + Check, + Clock, + ArrowRightLeft, + DollarSign, + Wallet, + ShoppingCart, + Coffee, + Home, + Car, + Zap, + User, + TrendingUp, + PlusCircle, + ShoppingBag, + Heart, + BookOpen, + Gamepad, + Tv, + PawPrint, + Plane, + Gift, + FileText, + MoreHorizontal, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const iconMap: Record = { + "dollar-sign": DollarSign, + wallet: Wallet, + "shopping-cart": ShoppingCart, + coffee: Coffee, + home: Home, + car: Car, + zap: Zap, + adjustment: ArrowRightLeft, + "trending-up": TrendingUp, + "plus-circle": PlusCircle, + "shopping-bag": ShoppingBag, + heart: Heart, + "book-open": BookOpen, + gamepad: Gamepad, + tv: Tv, + "paw-print": PawPrint, + plane: Plane, + gift: Gift, + "file-text": FileText, + "more-horizontal": MoreHorizontal, +}; + +interface TransactionItemProps { + id: string; + description: string; + amount: number; + type: "income" | "expense" | "transfer" | "adjustment"; + status: "paid" | "pending"; + categoryIconSlug?: string; + categoryColorHex?: string; + accountName: string; + accountColorHex?: string; + targetAccountName?: string; + targetAccountColorHex?: string; + userName?: string; + userAvatarUrl?: string; + isSystemReadonly?: boolean; + creditCardName?: string; + onClick?: () => void; +} + +export function TransactionItem({ + description, + amount, + type, + status, + categoryIconSlug, + categoryColorHex = "#52525b", // zinc-600 + accountName, + accountColorHex = "#a1a1aa", // zinc-400 + targetAccountName, + targetAccountColorHex, + userName, + userAvatarUrl, + isSystemReadonly, + creditCardName, + onClick, +}: TransactionItemProps) { + const IconComponent = categoryIconSlug ? iconMap[categoryIconSlug] || DollarSign : DollarSign; + + const formatCurrency = (val: number) => { + return new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(val); + }; + + const isIncome = type === "income"; + const isTransfer = type === "transfer"; + const isAdjustment = type === "adjustment"; + + return ( + + ); +} diff --git a/src/components/ui/TransactionListGroup.tsx b/src/components/ui/TransactionListGroup.tsx new file mode 100644 index 0000000..775a83f --- /dev/null +++ b/src/components/ui/TransactionListGroup.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { format, isToday, isYesterday } from "date-fns"; +import { ptBR } from "date-fns/locale"; + +interface TransactionListGroupProps { + date: Date | string; + children: React.ReactNode; +} + +export function TransactionListGroup({ date, children }: TransactionListGroupProps) { + const dateObj = new Date(date); + + // Convert to proper timezone if needed (naive approach for now) + dateObj.setHours(dateObj.getHours() + dateObj.getTimezoneOffset() / 60); + + let relativeDateStr = ""; + if (isToday(dateObj)) { + relativeDateStr = "Hoje"; + } else if (isYesterday(dateObj)) { + relativeDateStr = "Ontem"; + } else { + relativeDateStr = format(dateObj, "EEEE, d 'de' MMMM", { locale: ptBR }); + // Capitalize first letter + relativeDateStr = relativeDateStr.charAt(0).toUpperCase() + relativeDateStr.slice(1); + } + + return ( +
+

+ {relativeDateStr} +

+
{children}
+
+ ); +} diff --git a/src/components/ui/TransactionsHeader.tsx b/src/components/ui/TransactionsHeader.tsx new file mode 100644 index 0000000..251d402 --- /dev/null +++ b/src/components/ui/TransactionsHeader.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { Search } from "lucide-react"; +import { PageHeader } from "./PageHeader"; + +interface TransactionsHeaderProps { + searchQuery: string; + setSearchQuery: (query: string) => void; + totalAmount?: number; +} + +export function TransactionsHeader({ searchQuery, setSearchQuery, totalAmount }: TransactionsHeaderProps) { + const formatCurrency = (val: number) => { + return new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(val); + }; + + return ( + {formatCurrency(totalAmount)} + ) : ( + "Transações" + ) + } + action={ +
+
+ +
+ setSearchQuery(e.target.value)} + placeholder="Buscar transações..." + className="w-full bg-zinc-900/50 border border-zinc-800 rounded-xl py-3 pl-10 pr-4 text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all" + /> +
+ } + /> + ); +} diff --git a/src/types/supabase.ts b/src/types/supabase.ts index c05e45c..95392c6 100644 --- a/src/types/supabase.ts +++ b/src/types/supabase.ts @@ -79,9 +79,235 @@ export type Database = { } Relationships: [] } + categories: { + Row: { + color_hex: string | null + created_at: string + icon_slug: string + id: string + is_system: boolean | null + name: string + } + Insert: { + color_hex?: string | null + created_at?: string + icon_slug: string + id?: string + is_system?: boolean | null + name: string + } + Update: { + color_hex?: string | null + created_at?: string + icon_slug?: string + id?: string + is_system?: boolean | null + name?: string + } + Relationships: [] + } + credit_cards: { + Row: { + account_id: string + closing_day: number + created_at: string + due_day: number + id: string + limit_amount: number + name: string + } + Insert: { + account_id: string + closing_day: number + created_at?: string + due_day: number + id?: string + limit_amount?: number + name: string + } + Update: { + account_id?: string + closing_day?: number + created_at?: string + due_day?: number + id?: string + limit_amount?: number + name?: string + } + Relationships: [ + { + foreignKeyName: "credit_cards_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + ] + } + invoices: { + Row: { + amount: number + created_at: string + credit_card_id: string + due_date: string + id: string + reference_month: string + status: Database["public"]["Enums"]["transaction_status"] + system_transaction_id: string | null + } + Insert: { + amount?: number + created_at?: string + credit_card_id: string + due_date: string + id?: string + reference_month: string + status?: Database["public"]["Enums"]["transaction_status"] + system_transaction_id?: string | null + } + Update: { + amount?: number + created_at?: string + credit_card_id?: string + due_date?: string + id?: string + reference_month?: string + status?: Database["public"]["Enums"]["transaction_status"] + system_transaction_id?: string | null + } + Relationships: [ + { + foreignKeyName: "invoices_credit_card_id_fkey" + columns: ["credit_card_id"] + isOneToOne: false + referencedRelation: "credit_cards" + referencedColumns: ["id"] + }, + ] + } + transactions: { + Row: { + account_id: string + amount: number + category_id: string | null + created_at: string + credit_card_id: string | null + description: string + destination_account_id: string | null + group_id: string | null + id: string + installment_current: number | null + installment_total: number | null + invoice_id: string | null + is_recurring: boolean | null + is_system_readonly: boolean + status: Database["public"]["Enums"]["transaction_status"] + transaction_date: string + type: Database["public"]["Enums"]["transaction_type"] + updated_at: string + user_id: string + } + Insert: { + account_id: string + amount: number + category_id?: string | null + created_at?: string + credit_card_id?: string | null + description: string + destination_account_id?: string | null + group_id?: string | null + id?: string + installment_current?: number | null + installment_total?: number | null + invoice_id?: string | null + is_recurring?: boolean | null + is_system_readonly?: boolean + status?: Database["public"]["Enums"]["transaction_status"] + transaction_date: string + type: Database["public"]["Enums"]["transaction_type"] + updated_at?: string + user_id: string + } + Update: { + account_id?: string + amount?: number + category_id?: string | null + created_at?: string + credit_card_id?: string | null + description?: string + destination_account_id?: string | null + group_id?: string | null + id?: string + installment_current?: number | null + installment_total?: number | null + invoice_id?: string | null + is_recurring?: boolean | null + is_system_readonly?: boolean + status?: Database["public"]["Enums"]["transaction_status"] + transaction_date?: string + type?: Database["public"]["Enums"]["transaction_type"] + updated_at?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "transactions_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "transactions_category_id_fkey" + columns: ["category_id"] + isOneToOne: false + referencedRelation: "categories" + referencedColumns: ["id"] + }, + { + foreignKeyName: "transactions_credit_card_id_fkey" + columns: ["credit_card_id"] + isOneToOne: false + referencedRelation: "credit_cards" + referencedColumns: ["id"] + }, + { + foreignKeyName: "transactions_destination_account_id_fkey" + columns: ["destination_account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "transactions_invoice_id_fkey" + columns: ["invoice_id"] + isOneToOne: false + referencedRelation: "invoices" + referencedColumns: ["id"] + }, + ] + } } Views: { - [_ in never]: never + monthly_cashflow: { + Row: { + account_id: string | null + month_reference: string | null + projected_expense: number | null + projected_income: number | null + realized_expense: number | null + realized_income: number | null + } + Relationships: [ + { + foreignKeyName: "transactions_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + ] + } } Functions: { [_ in never]: never @@ -89,6 +315,8 @@ export type Database = { Enums: { access_role: "owner" | "editor" | "viewer" account_category: "checking" | "savings" | "wallet" | "vault" | "credit" + transaction_status: "pending" | "paid" + transaction_type: "income" | "expense" | "transfer" | "adjustment" } CompositeTypes: { [_ in never]: never @@ -218,6 +446,8 @@ export const Constants = { Enums: { access_role: ["owner", "editor", "viewer"], account_category: ["checking", "savings", "wallet", "vault", "credit"], + transaction_status: ["pending", "paid"], + transaction_type: ["income", "expense", "transfer", "adjustment"], }, }, } as const diff --git a/supabase/migrations/20260227231400_transactions_schema.sql b/supabase/migrations/20260227231400_transactions_schema.sql new file mode 100644 index 0000000..1eb5fdf --- /dev/null +++ b/supabase/migrations/20260227231400_transactions_schema.sql @@ -0,0 +1,106 @@ +-- supabase/migrations/00003_transactions_schema.sql + +-- ----------------------------------------------------------------------------- +-- Tipos de Domínio +-- ----------------------------------------------------------------------------- +CREATE TYPE transaction_type AS ENUM ('income', 'expense', 'transfer', 'adjustment'); +CREATE TYPE transaction_status AS ENUM ('pending', 'paid'); + +-- ----------------------------------------------------------------------------- +-- Tabela de Categorias (Auxiliar) +-- ----------------------------------------------------------------------------- +CREATE TABLE categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + icon_slug TEXT NOT NULL, + color_hex VARCHAR(7), + is_system BOOLEAN DEFAULT false, -- True para categorias imutáveis (ex: "Ajuste de Saldo") + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ----------------------------------------------------------------------------- +-- Tabela Principal de Transações +-- ----------------------------------------------------------------------------- +CREATE TABLE transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id), -- Quem criou (Sincronia Doméstica) + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + destination_account_id UUID REFERENCES accounts(id) ON DELETE CASCADE, + category_id UUID REFERENCES categories(id) ON DELETE SET NULL, + + type transaction_type NOT NULL, + status transaction_status NOT NULL DEFAULT 'paid', + + -- Valor absoluto. O sinal matemático é inferido pela aplicação baseada no 'type' + amount NUMERIC(15, 2) NOT NULL CHECK (amount >= 0), + description TEXT NOT NULL, + transaction_date DATE NOT NULL, + + -- Metadados de Parcelamento e Recorrência (Bússola Temporal) + group_id UUID, -- Agrupa transações recorrentes ou parceladas (gerado via app) + installment_current INTEGER CHECK (installment_current > 0), + installment_total INTEGER CHECK (installment_total >= installment_current), + is_recurring BOOLEAN DEFAULT false, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Restrições de Integridade Lógica + CONSTRAINT valid_transfer CHECK ( + (type = 'transfer' AND destination_account_id IS NOT NULL AND account_id != destination_account_id) OR + (type != 'transfer' AND destination_account_id IS NULL) + ) +); + +-- Índices para otimização de consultas da Bússola Temporal e Buscas +CREATE INDEX idx_transactions_account_date ON transactions(account_id, transaction_date); +CREATE INDEX idx_transactions_group_id ON transactions(group_id) WHERE group_id IS NOT NULL; +CREATE INDEX idx_transactions_user_id ON transactions(user_id); + +-- ----------------------------------------------------------------------------- +-- Segurança em Nível de Linha (RLS) +-- ----------------------------------------------------------------------------- +ALTER TABLE categories ENABLE ROW LEVEL SECURITY; +ALTER TABLE transactions ENABLE ROW LEVEL SECURITY; + +-- Categorias de sistema são visíveis para todos os usuários autenticados +CREATE POLICY "categorias_leitura_global" ON categories + FOR SELECT USING (auth.role() = 'authenticated'); + +-- Transações: O usuário só acessa transações de contas onde ele é membro (via account_members) +CREATE POLICY "transacoes_acesso_membros" ON transactions + FOR ALL USING ( + EXISTS ( + SELECT 1 FROM account_members + WHERE account_members.account_id = transactions.account_id + AND account_members.user_id = auth.uid() + ) + AND + ( + destination_account_id IS NULL OR EXISTS ( + SELECT 1 FROM account_members + WHERE account_members.account_id = transactions.destination_account_id + AND account_members.user_id = auth.uid() + ) + ) + ); + +-- ----------------------------------------------------------------------------- +-- View Otimizada para o Dashboard (Projeções) +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE VIEW monthly_cashflow AS +SELECT + account_id, + date_trunc('month', transaction_date)::date AS month_reference, + SUM(CASE WHEN type = 'income' AND status = 'paid' THEN amount ELSE 0 END) AS realized_income, + SUM(CASE WHEN type = 'expense' AND status = 'paid' THEN amount ELSE 0 END) AS realized_expense, + SUM(CASE WHEN type = 'income' AND status = 'pending' THEN amount ELSE 0 END) AS projected_income, + SUM(CASE WHEN type = 'expense' AND status = 'pending' THEN amount ELSE 0 END) AS projected_expense +FROM transactions +WHERE type IN ('income', 'expense') +GROUP BY account_id, date_trunc('month', transaction_date); + +-- Trigger de Auditoria (reaproveitando função da migration anterior) +CREATE TRIGGER update_transactions_modtime +BEFORE UPDATE ON transactions +FOR EACH ROW EXECUTE PROCEDURE update_modified_column(); diff --git a/supabase/migrations/20260228000000_seed_categories.sql b/supabase/migrations/20260228000000_seed_categories.sql new file mode 100644 index 0000000..232bc02 --- /dev/null +++ b/supabase/migrations/20260228000000_seed_categories.sql @@ -0,0 +1,24 @@ +-- supabase/migrations/20260228000000_seed_categories.sql + +INSERT INTO categories (name, icon_slug, color_hex, is_system) VALUES + ('Ajuste de Saldo', 'adjustment', '#52525b', true), + ('Conta Inicial', 'dollar-sign', '#10b981', true), + ('Salário', 'dollar-sign', '#10b981', false), + ('Rendimentos', 'trending-up', '#3b82f6', false), + ('Renda Extra', 'plus-circle', '#10b981', false), + ('Vendas', 'shopping-bag', '#10b981', false), + ('Alimentação', 'coffee', '#f59e0b', false), + ('Mercado', 'shopping-cart', '#84cc16', false), + ('Moradia', 'home', '#3b82f6', false), + ('Transporte', 'car', '#6366f1', false), + ('Saúde', 'heart', '#ef4444', false), + ('Educação', 'book-open', '#8b5cf6', false), + ('Lazer', 'gamepad', '#ec4899', false), + ('Compras', 'shopping-bag', '#f43f5e', false), + ('Contas & Serviços', 'zap', '#eab308', false), + ('Assinaturas', 'tv', '#a855f7', false), + ('Pets', 'paw-print', '#f97316', false), + ('Viagem', 'plane', '#14b8a6', false), + ('Presentes', 'gift', '#ec4899', false), + ('Impostos & Taxas', 'file-text', '#64748b', false), + ('Outros', 'more-horizontal', '#94a3b8', false); diff --git a/supabase/migrations/20260302095344_credit_cards_invoices.sql b/supabase/migrations/20260302095344_credit_cards_invoices.sql new file mode 100644 index 0000000..af54003 --- /dev/null +++ b/supabase/migrations/20260302095344_credit_cards_invoices.sql @@ -0,0 +1,151 @@ +-- supabase/migrations/20260302095344_credit_cards_invoices.sql + +-- 1. Criação das Entidades de Cartão de Crédito e Fatura +CREATE TABLE credit_cards ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, -- Conta corrente vinculada para pagamento + name TEXT NOT NULL, + closing_day INTEGER NOT NULL CHECK (closing_day BETWEEN 1 AND 31), + due_day INTEGER NOT NULL CHECK (due_day BETWEEN 1 AND 31), + limit_amount NUMERIC(15, 2) NOT NULL DEFAULT 0.00, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + credit_card_id UUID NOT NULL REFERENCES credit_cards(id) ON DELETE CASCADE, + reference_month DATE NOT NULL, + due_date DATE NOT NULL, + amount NUMERIC(15, 2) NOT NULL DEFAULT 0.00, + status transaction_status NOT NULL DEFAULT 'pending', + system_transaction_id UUID, -- FK condicional (referência à transação na conta corrente) + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(credit_card_id, reference_month) +); + +-- 2. Extensão do Schema de Transações +ALTER TABLE transactions + ADD COLUMN credit_card_id UUID REFERENCES credit_cards(id) ON DELETE CASCADE, + ADD COLUMN invoice_id UUID REFERENCES invoices(id) ON DELETE CASCADE, + ADD COLUMN is_system_readonly BOOLEAN NOT NULL DEFAULT false; + +-- 3. Proteção e Imutabilidade (Bloqueio de API) +CREATE OR REPLACE FUNCTION prevent_system_tx_mutation() +RETURNS TRIGGER AS $$ +BEGIN + -- A função current_setting avalia se a requisição originou-se da API (PostgREST) + -- Impede que o usuário edite ou apague a fatura "pré-criada" manualmente + IF OLD.is_system_readonly = true AND current_setting('request.jwt.claims', true) IS NOT NULL THEN + RAISE EXCEPTION 'Transações de sistema (Faturas) são gerenciadas automaticamente e não podem ser alteradas.'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER enforce_readonly_system_tx + BEFORE UPDATE OR DELETE ON transactions + FOR EACH ROW EXECUTE PROCEDURE prevent_system_tx_mutation(); + +-- 4. Motor de Roteamento de Faturas (Cálculo de Ciclo e Shadow Transaction) +CREATE OR REPLACE FUNCTION route_cc_transaction() +RETURNS TRIGGER AS $$ +DECLARE + v_closing_day INTEGER; + v_due_day INTEGER; + v_checking_account_id UUID; + v_invoice_month DATE; + v_due_date DATE; + v_invoice_id UUID; + v_shadow_tx_id UUID; +BEGIN + -- Bypass para transações normais (débito/dinheiro) + IF NEW.credit_card_id IS NULL THEN + RETURN NEW; + END IF; + + SELECT closing_day, due_day, account_id + INTO v_closing_day, v_due_day, v_checking_account_id + FROM credit_cards WHERE id = NEW.credit_card_id; + + -- Lógica de particionamento de ciclo: Compras feitas após o dia de fechamento caem no mês subsequente + IF EXTRACT(DAY FROM NEW.transaction_date) >= v_closing_day THEN + v_invoice_month := date_trunc('month', NEW.transaction_date) + INTERVAL '1 month'; + ELSE + v_invoice_month := date_trunc('month', NEW.transaction_date); + END IF; + + -- Padroniza o vencimento da fatura com base no due_day + v_due_date := v_invoice_month + (v_due_day - 1) * INTERVAL '1 day'; + + -- Upsert: Tenta localizar a fatura alvo + SELECT id, system_transaction_id INTO v_invoice_id, v_shadow_tx_id + FROM invoices + WHERE credit_card_id = NEW.credit_card_id AND reference_month = v_invoice_month; + + IF v_invoice_id IS NULL THEN + -- Injeta a "Shadow Transaction" na conta corrente alvo. is_system_readonly = true garante segurança. + INSERT INTO transactions ( + user_id, account_id, type, status, amount, description, transaction_date, is_system_readonly + ) VALUES ( + NEW.user_id, v_checking_account_id, 'expense', 'pending', 0.00, + 'Fatura Cartão - ' || to_char(v_invoice_month, 'MM/YYYY'), + v_due_date, true + ) RETURNING id INTO v_shadow_tx_id; + + -- Cria o registro físico da fatura vinculada à transação sombra + INSERT INTO invoices ( + credit_card_id, reference_month, due_date, amount, system_transaction_id + ) VALUES ( + NEW.credit_card_id, v_invoice_month, v_due_date, 0.00, v_shadow_tx_id + ) RETURNING id INTO v_invoice_id; + END IF; + + -- Vincula a transação (ou a parcela atual) à fatura alocada + NEW.invoice_id := v_invoice_id; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_route_cc_transaction + BEFORE INSERT OR UPDATE OF transaction_date, amount, credit_card_id ON transactions + FOR EACH ROW EXECUTE PROCEDURE route_cc_transaction(); + +-- 5. Atualização Reativa de Saldos (After Trigger) +-- Declarada com SECURITY DEFINER para que possua privilégios internos capazes de transpor o bloqueio de edição +CREATE OR REPLACE FUNCTION update_invoice_totals() +RETURNS TRIGGER AS $$ +DECLARE + v_invoice_id UUID; + v_shadow_tx_id UUID; + v_total NUMERIC(15, 2); +BEGIN + IF TG_OP = 'DELETE' THEN + v_invoice_id := OLD.invoice_id; + ELSE + v_invoice_id := NEW.invoice_id; + END IF; + + IF v_invoice_id IS NOT NULL THEN + -- Agregação do somatório da fatura + SELECT COALESCE(SUM(amount), 0.00) INTO v_total + FROM transactions + WHERE invoice_id = v_invoice_id AND id != (SELECT system_transaction_id FROM invoices WHERE id = v_invoice_id); + + -- Propaga a alteração de estado para a Fatura + UPDATE invoices SET amount = v_total WHERE id = v_invoice_id + RETURNING system_transaction_id INTO v_shadow_tx_id; + + -- Propaga a alteração de estado para a Shadow Transaction (refletindo no Fluxo de Caixa global) + IF v_shadow_tx_id IS NOT NULL THEN + UPDATE transactions SET amount = v_total WHERE id = v_shadow_tx_id; + END IF; + END IF; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE TRIGGER trigger_update_invoice_totals + AFTER INSERT OR UPDATE OF amount, invoice_id OR DELETE ON transactions + FOR EACH ROW EXECUTE PROCEDURE update_invoice_totals(); diff --git a/supabase/migrations/20260302135551_rls_for_credit_cards.sql b/supabase/migrations/20260302135551_rls_for_credit_cards.sql new file mode 100644 index 0000000..682e477 --- /dev/null +++ b/supabase/migrations/20260302135551_rls_for_credit_cards.sql @@ -0,0 +1,91 @@ +-- Enable RLS on the new tables +ALTER TABLE credit_cards ENABLE ROW LEVEL SECURITY; +ALTER TABLE invoices ENABLE ROW LEVEL SECURITY; + +-- Credit Cards Policies +-- Users can view credit cards if they have access to the linked account +CREATE POLICY "Users can view their credit cards" + ON credit_cards FOR SELECT + USING ( + account_id IN ( + SELECT account_id FROM account_members WHERE user_id = auth.uid() + ) + ); + +-- Users can insert credit cards if they own the linked account +CREATE POLICY "Users can insert their credit cards" + ON credit_cards FOR INSERT + WITH CHECK ( + account_id IN ( + SELECT account_id FROM account_members WHERE user_id = auth.uid() + ) + ); + +-- Users can update credit cards if they have access to the linked account +CREATE POLICY "Users can update their credit cards" + ON credit_cards FOR UPDATE + USING ( + account_id IN ( + SELECT account_id FROM account_members WHERE user_id = auth.uid() + ) + ) + WITH CHECK ( + account_id IN ( + SELECT account_id FROM account_members WHERE user_id = auth.uid() + ) + ); + +-- Users can delete credit cards if they have access to the linked account +CREATE POLICY "Users can delete their credit cards" + ON credit_cards FOR DELETE + USING ( + account_id IN ( + SELECT account_id FROM account_members WHERE user_id = auth.uid() + ) + ); + + +-- Invoices Policies +-- Users can view invoices if they have access to the credit card +CREATE POLICY "Users can view their invoices" + ON invoices FOR SELECT + USING ( + credit_card_id IN ( + SELECT id FROM credit_cards WHERE account_id IN ( + SELECT account_id FROM account_members WHERE user_id = auth.uid() + ) + ) + ); + +-- Users can insert invoices if they have access to the credit card +CREATE POLICY "Users can insert their invoices" + ON invoices FOR INSERT + WITH CHECK ( + credit_card_id IN ( + SELECT id FROM credit_cards WHERE account_id IN ( + SELECT account_id FROM account_members WHERE user_id = auth.uid() + ) + ) + ); + +-- Users can update invoices if they have access to the credit card +CREATE POLICY "Users can update their invoices" + ON invoices FOR UPDATE + USING ( + credit_card_id IN ( + SELECT id FROM credit_cards WHERE account_id IN ( + SELECT account_id FROM account_members WHERE user_id = auth.uid() + ) + ) + ); + +-- Users can delete invoices if they have access to the credit card +CREATE POLICY "Users can delete their invoices" + ON invoices FOR DELETE + USING ( + credit_card_id IN ( + SELECT id FROM credit_cards WHERE account_id IN ( + SELECT account_id FROM account_members WHERE user_id = auth.uid() + ) + ) + ); diff --git a/supabase/migrations/20260302141325_fix_system_tx_mutation_trigger.sql b/supabase/migrations/20260302141325_fix_system_tx_mutation_trigger.sql new file mode 100644 index 0000000..9a06ea7 --- /dev/null +++ b/supabase/migrations/20260302141325_fix_system_tx_mutation_trigger.sql @@ -0,0 +1,48 @@ +-- Atualiza a função de bloqueio de mutação para permitir bypass interno através do config customizado 'finiza.bypass_readonly' +CREATE OR REPLACE FUNCTION prevent_system_tx_mutation() +RETURNS TRIGGER AS $$ +BEGIN + -- A função current_setting avalia se a requisição originou-se da API (PostgREST) + -- Impede que o usuário edite ou apague a fatura "pré-criada" manualmente + IF OLD.is_system_readonly = true AND current_setting('request.jwt.claims', true) IS NOT NULL AND current_setting('finiza.bypass_readonly', true) IS DISTINCT FROM 'true' THEN + RAISE EXCEPTION 'Transações de sistema (Faturas) são gerenciadas automaticamente e não podem ser alteradas.'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Atualiza o agregador para definir a configuração de bypass da transação de sistema readonly temporalmente +CREATE OR REPLACE FUNCTION update_invoice_totals() +RETURNS TRIGGER AS $$ +DECLARE + v_invoice_id UUID; + v_shadow_tx_id UUID; + v_total NUMERIC(15, 2); +BEGIN + IF TG_OP = 'DELETE' THEN + v_invoice_id := OLD.invoice_id; + ELSE + v_invoice_id := NEW.invoice_id; + END IF; + + IF v_invoice_id IS NOT NULL THEN + -- Agregação do somatório da fatura + SELECT COALESCE(SUM(amount), 0.00) INTO v_total + FROM transactions + WHERE invoice_id = v_invoice_id AND id != (SELECT system_transaction_id FROM invoices WHERE id = v_invoice_id); + + -- Propaga a alteração de estado para a Fatura + UPDATE invoices SET amount = v_total WHERE id = v_invoice_id + RETURNING system_transaction_id INTO v_shadow_tx_id; + + -- Propaga a alteração de estado para a Shadow Transaction (refletindo no Fluxo de Caixa global) + IF v_shadow_tx_id IS NOT NULL THEN + PERFORM set_config('finiza.bypass_readonly', 'true', true); + UPDATE transactions SET amount = v_total WHERE id = v_shadow_tx_id; + PERFORM set_config('finiza.bypass_readonly', 'false', true); + END IF; + END IF; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER;