refactor(core): move app para src e padroniza estrutura

This commit is contained in:
Felipe Coutinho
2026-03-12 19:22:50 +00:00
parent d92e70f1b9
commit b0fbb1062a
567 changed files with 8981 additions and 5014 deletions

View File

@@ -0,0 +1,21 @@
import {
LANCAMENTO_CONDITIONS,
LANCAMENTO_PAYMENT_METHODS,
LANCAMENTO_TRANSACTION_TYPES,
} from "@/features/transactions/constants";
export const INITIAL_BALANCE_CATEGORY_NAME = "Saldo inicial";
export const INITIAL_BALANCE_NOTE = "saldo inicial";
export const INITIAL_BALANCE_CONDITION =
LANCAMENTO_CONDITIONS.find((condition) => condition === "À vista") ??
"À vista";
export const INITIAL_BALANCE_PAYMENT_METHOD =
LANCAMENTO_PAYMENT_METHODS.find((method) => method === "Pix") ?? "Pix";
export const INITIAL_BALANCE_TRANSACTION_TYPE =
LANCAMENTO_TRANSACTION_TYPES.find((type) => type === "Receita") ?? "Receita";
export const ACCOUNT_AUTO_INVOICE_NOTE_PREFIX = "AUTO_FATURA:";
export const buildInvoicePaymentNote = (cardId: string, period: string) =>
`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}${cardId}:${period}`;

View File

@@ -0,0 +1,65 @@
import { revalidatePath, revalidateTag } from "next/cache";
import { z } from "zod";
export type { ActionResult } from "@/shared/lib/types/actions";
import type { ActionResult } from "@/shared/lib/types/actions";
import { errorResult } from "@/shared/lib/types/actions";
/**
* Handles errors in server actions consistently
* @param error - The error to handle
* @returns ActionResult with error message
*/
export function handleActionError(error: unknown): ActionResult {
if (error instanceof z.ZodError) {
return errorResult(error.issues[0]?.message ?? "Dados inválidos.");
}
console.error("[ActionError]", error);
return errorResult("Ocorreu um erro inesperado. Tente novamente.");
}
/**
* Configuration for revalidation after mutations
*/
export const revalidateConfig = {
cartoes: ["/cards", "/accounts", "/transactions"],
contas: ["/accounts", "/transactions"],
categorias: ["/categories"],
estabelecimentos: ["/reports/establishments", "/transactions"],
orcamentos: ["/budgets"],
pagadores: ["/payers"],
anotacoes: ["/notes", "/notes/arquivadas", "/dashboard"],
lancamentos: ["/transactions", "/accounts"],
inbox: ["/inbox", "/transactions", "/dashboard"],
recorrentes: ["/transactions", "/dashboard"],
} as const;
/** Entities whose mutations should invalidate the dashboard cache */
const DASHBOARD_ENTITIES: ReadonlySet<string> = new Set([
"lancamentos",
"contas",
"cartoes",
"orcamentos",
"pagadores",
"anotacoes",
"inbox",
"recorrentes",
]);
/**
* Revalidates paths for a specific entity.
* Also invalidates the dashboard "use cache" tag for financial entities.
* @param entity - The entity type
*/
export function revalidateForEntity(
entity: keyof typeof revalidateConfig,
): void {
revalidateConfig[entity].forEach((path) => revalidatePath(path));
// Invalidate dashboard cache for financial mutations
if (DASHBOARD_ENTITIES.has(entity)) {
revalidateTag("dashboard", "max");
}
}

View File

@@ -0,0 +1,229 @@
import crypto from "node:crypto";
function getJwtSecret(): string {
const secret = process.env.BETTER_AUTH_SECRET;
if (!secret) {
throw new Error(
"BETTER_AUTH_SECRET is required. Set it in your .env file.",
);
}
return secret;
}
const ACCESS_TOKEN_EXPIRY = 7 * 24 * 60 * 60; // 7 days in seconds
const REFRESH_TOKEN_EXPIRY = 90 * 24 * 60 * 60; // 90 days in seconds
// ============================================================================
// TYPES
// ============================================================================
export interface JwtPayload {
sub: string; // userId
type: "api_access" | "api_refresh";
tokenId: string;
deviceId?: string;
iat: number;
exp: number;
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
tokenId: string;
expiresAt: Date;
}
// ============================================================================
// JWT UTILITIES
// ============================================================================
/**
* Base64URL encode a string
*/
function base64UrlEncode(str: string): string {
return Buffer.from(str)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
/**
* Base64URL decode a string
*/
function base64UrlDecode(str: string): string {
str = str.replace(/-/g, "+").replace(/_/g, "/");
const pad = str.length % 4;
if (pad) {
str += "=".repeat(4 - pad);
}
return Buffer.from(str, "base64").toString();
}
/**
* Create HMAC-SHA256 signature
*/
function createSignature(data: string): string {
return crypto
.createHmac("sha256", getJwtSecret())
.update(data)
.digest("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
/**
* Create a JWT token
*/
export function createJwt(
payload: Omit<JwtPayload, "iat" | "exp">,
expiresIn: number,
): string {
const header = { alg: "HS256", typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const fullPayload: JwtPayload = {
...payload,
iat: now,
exp: now + expiresIn,
};
const headerEncoded = base64UrlEncode(JSON.stringify(header));
const payloadEncoded = base64UrlEncode(JSON.stringify(fullPayload));
const signature = createSignature(`${headerEncoded}.${payloadEncoded}`);
return `${headerEncoded}.${payloadEncoded}.${signature}`;
}
/**
* Verify and decode a JWT token
* @returns The decoded payload or null if invalid
*/
export function verifyJwt(token: string): JwtPayload | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [headerEncoded, payloadEncoded, signature] = parts;
const expectedSignature = createSignature(
`${headerEncoded}.${payloadEncoded}`,
);
// Constant-time comparison to prevent timing attacks
if (
!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
)
) {
return null;
}
const payload: JwtPayload = JSON.parse(base64UrlDecode(payloadEncoded));
// Check expiration
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) {
return null;
}
return payload;
} catch {
return null;
}
}
// ============================================================================
// TOKEN GENERATION
// ============================================================================
/**
* Generate a random token ID
*/
export function generateTokenId(): string {
return crypto.randomUUID();
}
/**
* Hash a token using SHA-256
*/
export function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
/**
* Get the display prefix of a token (first 8 chars after prefix)
*/
export function getTokenPrefix(token: string): string {
// Remove "os_" prefix and get first 8 chars
const withoutPrefix = token.replace(/^os_/, "");
return `os_${withoutPrefix.substring(0, 8)}...`;
}
/**
* Generate a complete token pair (access + refresh)
*/
export function generateTokenPair(
userId: string,
deviceId?: string,
): TokenPair {
const tokenId = generateTokenId();
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000);
const accessToken = createJwt(
{ sub: userId, type: "api_access", tokenId, deviceId },
ACCESS_TOKEN_EXPIRY,
);
const refreshToken = createJwt(
{ sub: userId, type: "api_refresh", tokenId, deviceId },
REFRESH_TOKEN_EXPIRY,
);
return {
accessToken,
refreshToken,
tokenId,
expiresAt,
};
}
/**
* Refresh an access token using a refresh token
*/
export function refreshAccessToken(
refreshToken: string,
): { accessToken: string; expiresAt: Date } | null {
const payload = verifyJwt(refreshToken);
if (!payload || payload.type !== "api_refresh") {
return null;
}
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000);
const accessToken = createJwt(
{
sub: payload.sub,
type: "api_access",
tokenId: payload.tokenId,
deviceId: payload.deviceId,
},
ACCESS_TOKEN_EXPIRY,
);
return { accessToken, expiresAt };
}
// ============================================================================
// VALIDATION HELPERS
// ============================================================================
/**
* Extract bearer token from Authorization header
*/
export function extractBearerToken(authHeader: string | null): string | null {
if (!authHeader) return null;
const match = authHeader.match(/^Bearer\s+(.+)$/i);
return match ? match[1] : null;
}

View File

@@ -0,0 +1,23 @@
import { passkeyClient } from "@better-auth/passkey/client";
import { createAuthClient } from "better-auth/react";
const baseURL = process.env.BETTER_AUTH_URL?.replace(/\/$/, "");
export const authClient = createAuthClient({
...(baseURL ? { baseURL } : {}),
plugins: [passkeyClient()],
});
/**
* Indica se o login com Google está habilitado.
* Verifica se as credenciais do Google OAuth estão configuradas.
*
* IMPORTANTE: Como variáveis de ambiente sem prefixo NEXT_PUBLIC_ não estão
* disponíveis no cliente, esta verificação deve ser feita no servidor.
* Por isso, sempre retornamos true aqui e a validação real acontece no servidor.
*
* Para desabilitar o Google OAuth, remova ou deixe vazias as variáveis:
* - GOOGLE_CLIENT_ID
* - GOOGLE_CLIENT_SECRET
*/
export const googleSignInAvailable = true;

View File

@@ -0,0 +1,147 @@
import { passkey } from "@better-auth/passkey";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import type { GoogleProfile } from "better-auth/social-providers";
import { seedDefaultCategoriesForUser } from "@/shared/lib/categories/defaults";
import { db, schema } from "@/shared/lib/db";
import { ensureDefaultPagadorForUser } from "@/shared/lib/payers/defaults";
import { normalizeNameFromEmail } from "@/shared/lib/payers/utils";
// ============================================================================
// GOOGLE OAUTH CONFIGURATION
// ============================================================================
const googleClientId = process.env.GOOGLE_CLIENT_ID;
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
/**
* Extrai nome do usuário do perfil do Google com fallback hierárquico:
* 1. profile.name (nome completo)
* 2. profile.given_name + profile.family_name
* 3. Nome extraído do email
* 4. "Usuário" (fallback final)
*/
function getNameFromGoogleProfile(profile: GoogleProfile): string {
const fullName = profile.name?.trim();
if (fullName) return fullName;
const fromGivenFamily = [profile.given_name, profile.family_name]
.filter(Boolean)
.join(" ")
.trim();
if (fromGivenFamily) return fromGivenFamily;
const fromEmail = profile.email
? normalizeNameFromEmail(profile.email)
: undefined;
return fromEmail ?? "Usuário";
}
// ============================================================================
// BETTER AUTH INSTANCE
// ============================================================================
export const auth = betterAuth({
// Base URL configuration
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
// Trust host configuration for production environments
trustedOrigins: process.env.BETTER_AUTH_URL
? [process.env.BETTER_AUTH_URL]
: [],
// Email/Password authentication
emailAndPassword: {
enabled: true,
autoSignIn: true,
},
// Database adapter (Drizzle + PostgreSQL)
database: drizzleAdapter(db, {
provider: "pg",
schema,
camelCase: true,
}),
// Session configuration - Safari compatibility
session: {
cookieCache: {
enabled: true,
maxAge: 60 * 5, // 5 minutes
},
},
// Advanced configuration for Safari compatibility
advanced: {
cookieOptions: {
sameSite: "lax", // Safari compatible
secure: process.env.NODE_ENV === "production", // HTTPS in production only
httpOnly: true,
},
crossSubDomainCookies: {
enabled: false, // Disable for better Safari compatibility
},
},
// Plugins
plugins: [
passkey({
rpName: "OpenMonetis",
}),
],
// Google OAuth (se configurado)
socialProviders:
googleClientId && googleClientSecret
? {
google: {
clientId: googleClientId,
clientSecret: googleClientSecret,
mapProfileToUser: (profile) => ({
name: getNameFromGoogleProfile(profile),
email: profile.email,
image: profile.picture,
emailVerified: profile.email_verified,
}),
},
}
: undefined,
// Database hooks - Executados após eventos do DB
databaseHooks: {
user: {
create: {
/**
* Após criar novo usuário, inicializa:
* 1. Categorias padrão (Receitas/Despesas)
* 2. Pagador padrão (vinculado ao usuário)
*/
after: async (user) => {
// Se falhar aqui, o usuário já foi criado - considere usar queue para retry
try {
await seedDefaultCategoriesForUser(user.id);
await ensureDefaultPagadorForUser({
id: user.id,
name: user.name ?? undefined,
email: user.email ?? undefined,
image: user.image ?? undefined,
});
} catch (error) {
console.error(
"[Auth] Falha ao criar dados padrão do usuário:",
error,
);
}
},
},
},
},
});
// Aviso em desenvolvimento se Google OAuth não estiver configurado
if (!googleClientId && process.env.NODE_ENV === "development") {
console.warn(
"[Auth] Google OAuth não configurado. Defina GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET.",
);
}

View File

@@ -0,0 +1,66 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { cache } from "react";
import { auth } from "@/shared/lib/auth/config";
/**
* Cached session fetch - deduplicates auth calls within a single request.
* Layout + page calling getUser() will only hit auth once.
*/
const getSessionCached = cache(async () => {
return auth.api.getSession({ headers: await headers() });
});
/**
* Gets the current authenticated user
* @returns User object
* @throws Redirects to /login if user is not authenticated
*/
export async function getUser() {
const session = await getSessionCached();
if (!session?.user) {
redirect("/login");
}
return session.user;
}
/**
* Gets the current authenticated user ID
* @returns User ID string
* @throws Redirects to /login if user is not authenticated
*/
export async function getUserId() {
const session = await getSessionCached();
if (!session?.user) {
redirect("/login");
}
return session.user.id;
}
/**
* Gets the current authenticated session
* @returns Full session object including user
* @throws Redirects to /login if user is not authenticated
*/
export async function getUserSession() {
const session = await getSessionCached();
if (!session?.user) {
redirect("/login");
}
return session;
}
/**
* Gets the current session without requiring authentication
* @returns Session object or null if not authenticated
* @note This function does not redirect if user is not authenticated
*/
export async function getOptionalUserSession() {
return getSessionCached();
}

View File

@@ -0,0 +1,143 @@
import { useEffect } from "react";
import type { Operator } from "@/shared/utils/calculator";
type UseCalculatorKeyboardParams = {
isOpen: boolean;
canCopy: boolean;
onCopy: () => void | Promise<void>;
onPaste: () => void | Promise<void>;
inputDigit: (digit: string) => void;
inputDecimal: () => void;
setNextOperator: (op: Operator) => void;
evaluate: () => void;
deleteLastDigit: () => void;
reset: () => void;
applyPercent: () => void;
};
function shouldIgnoreForEditableTarget(target: EventTarget | null): boolean {
if (!target || !(target instanceof HTMLElement)) {
return false;
}
const tagName = target.tagName;
return (
tagName === "INPUT" || tagName === "TEXTAREA" || target.isContentEditable
);
}
const KEY_TO_OPERATOR: Record<string, Operator> = {
"+": "add",
"-": "subtract",
"*": "multiply",
"/": "divide",
};
export function useCalculatorKeyboard({
isOpen,
canCopy,
onCopy,
onPaste,
inputDigit,
inputDecimal,
setNextOperator,
evaluate,
deleteLastDigit,
reset,
applyPercent,
}: UseCalculatorKeyboardParams) {
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
const { key, ctrlKey, metaKey } = event;
// Ctrl/Cmd shortcuts
if (ctrlKey || metaKey) {
if (shouldIgnoreForEditableTarget(event.target)) return;
const lowerKey = key.toLowerCase();
if (lowerKey === "c" && canCopy) {
const selection = window.getSelection();
if (selection && selection.toString().trim().length > 0) return;
event.preventDefault();
void onCopy();
} else if (lowerKey === "v") {
const selection = window.getSelection();
if (selection && selection.toString().trim().length > 0) return;
if (!navigator.clipboard?.readText) return;
event.preventDefault();
void onPaste();
}
return;
}
// Digits
if (key >= "0" && key <= "9") {
event.preventDefault();
inputDigit(key);
return;
}
// Decimal
if (key === "." || key === ",") {
event.preventDefault();
inputDecimal();
return;
}
// Operators
const op = KEY_TO_OPERATOR[key];
if (op) {
event.preventDefault();
setNextOperator(op);
return;
}
// Evaluate
if (key === "Enter" || key === "=") {
event.preventDefault();
evaluate();
return;
}
// Backspace
if (key === "Backspace") {
event.preventDefault();
deleteLastDigit();
return;
}
// Escape resets calculator (dialog close is handled by onEscapeKeyDown)
if (key === "Escape") {
event.preventDefault();
reset();
return;
}
// Percent
if (key === "%") {
event.preventDefault();
applyPercent();
return;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [
isOpen,
canCopy,
onCopy,
onPaste,
inputDigit,
inputDecimal,
setNextOperator,
evaluate,
deleteLastDigit,
reset,
applyPercent,
]);
}

View File

@@ -0,0 +1,379 @@
import type { VariantProps } from "class-variance-authority";
import { useEffect, useRef, useState } from "react";
import type { buttonVariants } from "@/shared/components/ui/button";
import {
formatLocaleValue,
formatNumber,
normalizeClipboardNumber,
OPERATOR_SYMBOLS,
type Operator,
performOperation,
} from "@/shared/utils/calculator";
export type CalculatorButtonConfig = {
label: string;
onClick: () => void;
variant?: VariantProps<typeof buttonVariants>["variant"];
colSpan?: number;
className?: string;
};
export function useCalculatorState() {
const [display, setDisplay] = useState("0");
const [accumulator, setAccumulator] = useState<number | null>(null);
const [operator, setOperator] = useState<Operator | null>(null);
const [overwrite, setOverwrite] = useState(false);
const [history, setHistory] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const resetCopiedTimeoutRef = useRef<number | undefined>(undefined);
const currentValue = Number(display);
const resultText = (() => {
if (display === "Erro") {
return null;
}
const normalized = formatNumber(currentValue);
if (normalized === "Erro") {
return null;
}
return formatLocaleValue(normalized);
})();
const reset = () => {
setDisplay("0");
setAccumulator(null);
setOperator(null);
setOverwrite(false);
setHistory(null);
};
const inputDigit = (digit: string) => {
// Check conditions before state updates
const shouldReset = overwrite || display === "Erro";
setDisplay((prev) => {
if (shouldReset) {
return digit;
}
if (prev === "0") {
return digit;
}
// Limitar a 10 dígitos (excluindo sinal negativo e ponto decimal)
const digitCount = prev.replace(/[-.]/g, "").length;
if (digitCount >= 10) {
return prev;
}
return `${prev}${digit}`;
});
// Update related states after display update
if (shouldReset) {
setOverwrite(false);
setHistory(null);
}
};
const inputDecimal = () => {
// Check conditions before state updates
const shouldReset = overwrite || display === "Erro";
setDisplay((prev) => {
if (shouldReset) {
return "0.";
}
if (prev.includes(".")) {
return prev;
}
// Limitar a 10 dígitos antes de adicionar o ponto decimal
const digitCount = prev.replace(/[-]/g, "").length;
if (digitCount >= 10) {
return prev;
}
return `${prev}.`;
});
// Update related states after display update
if (shouldReset) {
setOverwrite(false);
setHistory(null);
}
};
const setNextOperator = (nextOperator: Operator) => {
if (display === "Erro") {
reset();
return;
}
const value = currentValue;
if (accumulator === null || operator === null || overwrite) {
setAccumulator(value);
} else {
const result = performOperation(accumulator, value, operator);
const formatted = formatNumber(result);
setAccumulator(Number.isFinite(result) ? result : null);
setDisplay(formatted);
if (!Number.isFinite(result)) {
setOperator(null);
setOverwrite(true);
setHistory(null);
return;
}
}
setOperator(nextOperator);
setOverwrite(true);
setHistory(null);
};
const evaluate = () => {
if (operator === null || accumulator === null || display === "Erro") {
return;
}
const value = currentValue;
const left = formatNumber(accumulator);
const right = formatNumber(value);
const symbol = OPERATOR_SYMBOLS[operator];
const operation = `${formatLocaleValue(left)} ${symbol} ${formatLocaleValue(
right,
)}`;
const result = performOperation(accumulator, value, operator);
const formatted = formatNumber(result);
setDisplay(formatted);
setAccumulator(Number.isFinite(result) ? result : null);
setOperator(null);
setOverwrite(true);
setHistory(operation);
};
const toggleSign = () => {
setDisplay((prev) => {
if (prev === "Erro") {
return prev;
}
if (prev.startsWith("-")) {
return prev.slice(1);
}
return prev === "0" ? prev : `-${prev}`;
});
if (overwrite) {
setOverwrite(false);
setHistory(null);
}
};
const deleteLastDigit = () => {
setHistory(null);
// Check conditions before state updates
const isError = display === "Erro";
setDisplay((prev) => {
if (prev === "Erro") {
return "0";
}
if (overwrite) {
return "0";
}
if (prev.length <= 1 || (prev.length === 2 && prev.startsWith("-"))) {
return "0";
}
return prev.slice(0, -1);
});
// Update related states after display update
if (isError) {
setAccumulator(null);
setOperator(null);
setOverwrite(false);
} else if (overwrite) {
setOverwrite(false);
}
};
const applyPercent = () => {
setDisplay((prev) => {
if (prev === "Erro") {
return prev;
}
const value = Number(prev);
return formatNumber(value / 100);
});
setOverwrite(true);
setHistory(null);
};
const expression = (() => {
if (display === "Erro") {
return "Erro";
}
if (operator && accumulator !== null) {
const symbol = OPERATOR_SYMBOLS[operator];
const left = formatLocaleValue(formatNumber(accumulator));
if (overwrite) {
return `${left} ${symbol}`;
}
return `${left} ${symbol} ${formatLocaleValue(display)}`;
}
return formatLocaleValue(display);
})();
const makeOperatorHandler = (nextOperator: Operator) => () =>
setNextOperator(nextOperator);
const buttons: CalculatorButtonConfig[][] = [
[
{ label: "C", onClick: reset, variant: "destructive" },
{ label: "⌫", onClick: deleteLastDigit, variant: "secondary" },
{ label: "%", onClick: applyPercent, variant: "secondary" },
{
label: "÷",
onClick: makeOperatorHandler("divide"),
variant: "outline",
},
],
[
{ label: "7", onClick: () => inputDigit("7") },
{ label: "8", onClick: () => inputDigit("8") },
{ label: "9", onClick: () => inputDigit("9") },
{
label: "×",
onClick: makeOperatorHandler("multiply"),
variant: "outline",
},
],
[
{ label: "4", onClick: () => inputDigit("4") },
{ label: "5", onClick: () => inputDigit("5") },
{ label: "6", onClick: () => inputDigit("6") },
{
label: "-",
onClick: makeOperatorHandler("subtract"),
variant: "outline",
},
],
[
{ label: "1", onClick: () => inputDigit("1") },
{ label: "2", onClick: () => inputDigit("2") },
{ label: "3", onClick: () => inputDigit("3") },
{ label: "+", onClick: makeOperatorHandler("add"), variant: "outline" },
],
[
{ label: "±", onClick: toggleSign, variant: "secondary" },
{ label: "0", onClick: () => inputDigit("0") },
{ label: ",", onClick: inputDecimal },
{ label: "=", onClick: evaluate, variant: "default" },
],
];
const copyToClipboard = async () => {
if (!resultText) return;
try {
await navigator.clipboard.writeText(resultText);
setCopied(true);
if (resetCopiedTimeoutRef.current !== undefined) {
window.clearTimeout(resetCopiedTimeoutRef.current);
}
resetCopiedTimeoutRef.current = window.setTimeout(() => {
setCopied(false);
}, 2000);
} catch (error) {
console.error(
"Não foi possível copiar o resultado da calculadora.",
error,
);
}
};
const pasteFromClipboard = async () => {
if (!navigator.clipboard?.readText) return;
try {
const rawValue = await navigator.clipboard.readText();
const normalized = normalizeClipboardNumber(rawValue);
if (resetCopiedTimeoutRef.current !== undefined) {
window.clearTimeout(resetCopiedTimeoutRef.current);
resetCopiedTimeoutRef.current = undefined;
}
setCopied(false);
if (!normalized) {
setDisplay("Erro");
setAccumulator(null);
setOperator(null);
setOverwrite(true);
setHistory(null);
return;
}
// Limitar a 10 dígitos
const digitCount = normalized.replace(/[-.]/g, "").length;
if (digitCount > 10) {
setDisplay("Erro");
setAccumulator(null);
setOperator(null);
setOverwrite(true);
setHistory(null);
return;
}
setDisplay(normalized);
setAccumulator(null);
setOperator(null);
setOverwrite(false);
setHistory(null);
} catch (error) {
console.error("Não foi possível colar o valor na calculadora.", error);
}
};
useEffect(() => {
return () => {
if (resetCopiedTimeoutRef.current !== undefined) {
window.clearTimeout(resetCopiedTimeoutRef.current);
}
};
}, []);
return {
display,
operator,
expression,
history,
resultText,
copied,
buttons,
inputDigit,
inputDecimal,
setNextOperator,
evaluate,
deleteLastDigit,
reset,
applyPercent,
copyToClipboard,
pasteFromClipboard,
};
}

View File

@@ -0,0 +1,112 @@
import { useCallback, useRef } from "react";
type Position = { x: number; y: number };
const MIN_VISIBLE_PX = 20;
function clampPosition(
x: number,
y: number,
elementWidth: number,
elementHeight: number,
): Position {
// Dialog starts centered (left/top 50% + translate(-50%, -50%)).
// Clamp offsets so at least MIN_VISIBLE_PX remains visible on each axis.
const halfViewportWidth = window.innerWidth / 2;
const halfViewportHeight = window.innerHeight / 2;
const halfElementWidth = elementWidth / 2;
const halfElementHeight = elementHeight / 2;
const minX = MIN_VISIBLE_PX - (halfViewportWidth + halfElementWidth);
const maxX = halfViewportWidth + halfElementWidth - MIN_VISIBLE_PX;
const minY = MIN_VISIBLE_PX - (halfViewportHeight + halfElementHeight);
const maxY = halfViewportHeight + halfElementHeight - MIN_VISIBLE_PX;
return {
x: Math.min(Math.max(x, minX), maxX),
y: Math.min(Math.max(y, minY), maxY),
};
}
function applyPosition(el: HTMLElement, x: number, y: number) {
if (x === 0 && y === 0) {
el.style.translate = "";
el.style.transform = "";
} else {
// Keep the dialog's centered baseline (-50%, -50%) and only add drag offset.
el.style.translate = `calc(-50% + ${x}px) calc(-50% + ${y}px)`;
el.style.transform = "";
}
}
export function useDraggableDialog() {
const offset = useRef<Position>({ x: 0, y: 0 });
const dragStart = useRef<Position | null>(null);
const initialOffset = useRef<Position>({ x: 0, y: 0 });
const contentRef = useRef<HTMLElement | null>(null);
const onPointerDown = useCallback((e: React.PointerEvent<HTMLElement>) => {
if (e.button !== 0) return;
dragStart.current = { x: e.clientX, y: e.clientY };
initialOffset.current = { x: offset.current.x, y: offset.current.y };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}, []);
const onPointerMove = useCallback((e: React.PointerEvent<HTMLElement>) => {
if (!dragStart.current || !contentRef.current) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
const rawX = initialOffset.current.x + dx;
const rawY = initialOffset.current.y + dy;
const el = contentRef.current;
const clamped = clampPosition(rawX, rawY, el.offsetWidth, el.offsetHeight);
offset.current = clamped;
applyPosition(el, clamped.x, clamped.y);
}, []);
const onPointerUp = useCallback((e: React.PointerEvent<HTMLElement>) => {
dragStart.current = null;
if ((e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) {
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}
}, []);
const onPointerCancel = useCallback(() => {
dragStart.current = null;
}, []);
const onLostPointerCapture = useCallback(() => {
dragStart.current = null;
}, []);
const resetPosition = useCallback(() => {
offset.current = { x: 0, y: 0 };
if (contentRef.current) {
applyPosition(contentRef.current, 0, 0);
}
}, []);
const dragHandleProps = {
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onLostPointerCapture,
style: { touchAction: "none" as const, cursor: "grab" },
};
const contentRefCallback = useCallback((node: HTMLElement | null) => {
contentRef.current = node;
}, []);
return {
dragHandleProps,
contentRefCallback,
resetPosition,
};
}

View File

@@ -0,0 +1,45 @@
const CARD_BRAND_ASSET_BY_KEY = {
visa: "/flags/visa.svg",
mastercard: "/flags/mastercard.svg",
amex: "/flags/amex.svg",
american: "/flags/amex.svg",
elo: "/flags/elo.svg",
hipercard: "/flags/hipercard.svg",
hiper: "/flags/hipercard.svg",
} as const;
const CARD_BRAND_LOGO_BY_KEY = {
visa: "/logos/visa.png",
mastercard: "/logos/mastercard.png",
amex: "/logos/amex.png",
american: "/logos/amex.png",
elo: "/logos/elo.png",
hipercard: "/logos/hipercard.png",
hiper: "/logos/hipercard.png",
} as const;
const findMatchingCardBrandKey = (brand?: string | null) => {
if (!brand) {
return null;
}
const normalizedBrand = brand.trim().toLowerCase();
return (
(
Object.keys(CARD_BRAND_ASSET_BY_KEY) as Array<
keyof typeof CARD_BRAND_ASSET_BY_KEY
>
).find((key) => normalizedBrand.includes(key)) ?? null
);
};
export const resolveCardBrandAsset = (brand?: string | null) => {
const key = findMatchingCardBrandKey(brand);
return key ? CARD_BRAND_ASSET_BY_KEY[key] : null;
};
export const resolveCardBrandLogoSrc = (brand?: string | null) => {
const key = findMatchingCardBrandKey(brand);
return key ? CARD_BRAND_LOGO_BY_KEY[key] : null;
};

View File

@@ -0,0 +1,7 @@
export const DEFAULT_CARD_BRANDS = ["Visa", "Mastercard", "Elo"] as const;
export const DEFAULT_CARD_STATUS = ["Ativo", "Inativo"] as const;
export const DAYS_IN_MONTH = Array.from({ length: 31 }, (_, index) =>
String(index + 1).padStart(2, "0"),
);

View File

@@ -0,0 +1,8 @@
export const CATEGORY_TYPES = ["receita", "despesa"] as const;
export type CategoryType = (typeof CATEGORY_TYPES)[number];
export const CATEGORY_TYPE_LABEL: Record<CategoryType, string> = {
receita: "Receita",
despesa: "Despesa",
};

View File

@@ -0,0 +1,83 @@
import { eq } from "drizzle-orm";
import { categorias } from "@/db/schema";
import type { CategoryType } from "@/shared/lib/categories/constants";
import { db } from "@/shared/lib/db";
type DefaultCategory = {
name: string;
type: CategoryType;
icon: string | null;
};
export const DEFAULT_CATEGORIES: DefaultCategory[] = [
// Despesas
{ name: "Alimentação", type: "despesa", icon: "RiRestaurant2Line" },
{ name: "Transporte", type: "despesa", icon: "RiBusLine" },
{ name: "Moradia", type: "despesa", icon: "RiHomeLine" },
{ name: "Saúde", type: "despesa", icon: "RiStethoscopeLine" },
{ name: "Educação", type: "despesa", icon: "RiBook2Line" },
{ name: "Lazer", type: "despesa", icon: "RiGamepadLine" },
{ name: "Compras", type: "despesa", icon: "RiShoppingBagLine" },
{ name: "Assinaturas", type: "despesa", icon: "RiServiceLine" },
{ name: "Pets", type: "despesa", icon: "RiBearSmileLine" },
{ name: "Mercado", type: "despesa", icon: "RiShoppingBasketLine" },
{ name: "Restaurantes", type: "despesa", icon: "RiRestaurantLine" },
{ name: "Delivery", type: "despesa", icon: "RiMotorbikeLine" },
{ name: "Energia e água", type: "despesa", icon: "RiFlashlightLine" },
{ name: "Internet", type: "despesa", icon: "RiWifiLine" },
{ name: "Vestuário", type: "despesa", icon: "RiTShirtLine" },
{ name: "Viagem", type: "despesa", icon: "RiFlightTakeoffLine" },
{ name: "Presentes", type: "despesa", icon: "RiGiftLine" },
{ name: "Pagamentos", type: "despesa", icon: "RiBillLine" },
{ name: "Outras despesas", type: "despesa", icon: "RiMore2Line" },
// Receitas
{ name: "Salário", type: "receita", icon: "RiWallet3Line" },
{ name: "Freelance", type: "receita", icon: "RiUserStarLine" },
{ name: "Investimentos", type: "receita", icon: "RiStockLine" },
{ name: "Vendas", type: "receita", icon: "RiShoppingCartLine" },
{ name: "Prêmios", type: "receita", icon: "RiMedalLine" },
{ name: "Reembolso", type: "receita", icon: "RiRefundLine" },
{ name: "Aluguel recebido", type: "receita", icon: "RiBuilding2Line" },
{ name: "Outras receitas", type: "receita", icon: "RiMore2Line" },
{ name: "Saldo inicial", type: "receita", icon: "RiWallet2Line" },
// Categoria especial para transferências entre contas
{
name: "Transferência interna",
type: "receita",
icon: "RiArrowLeftRightLine",
},
];
/**
* Seeds default categories for a new user
* @param userId - User ID to seed categories for
*/
export async function seedDefaultCategoriesForUser(userId: string | undefined) {
if (!userId) {
return;
}
const existing = await db.query.categorias.findFirst({
columns: { id: true },
where: eq(categorias.userId, userId),
});
if (existing) {
return;
}
if (DEFAULT_CATEGORIES.length === 0) {
return;
}
await db.insert(categorias).values(
DEFAULT_CATEGORIES.map((category) => ({
name: category.name,
type: category.type,
icon: category.icon,
userId,
})),
);
}

View File

@@ -0,0 +1,173 @@
/**
* Category icon options and utilities
*
* Consolidated from:
* - /lib/category-icons.ts
*/
export type CategoryIconOption = {
label: string;
value: string;
};
export const CATEGORY_ICON_OPTIONS: CategoryIconOption[] = [
// Finanças
{ label: "Dinheiro", value: "RiMoneyDollarCircleLine" },
{ label: "Carteira", value: "RiWallet3Line" },
{ label: "Carteira 2", value: "RiWalletLine" },
{ label: "Cartão", value: "RiBankCard2Line" },
{ label: "Banco", value: "RiBankLine" },
{ label: "Moedas", value: "RiHandCoinLine" },
{ label: "Gráfico", value: "RiLineChartLine" },
{ label: "Ações", value: "RiStockLine" },
{ label: "Troca", value: "RiExchangeLine" },
{ label: "Reembolso", value: "RiRefundLine" },
{ label: "Recompensa", value: "RiRefund2Line" },
{ label: "Leilão", value: "RiAuctionLine" },
// Compras
{ label: "Carrinho", value: "RiShoppingCartLine" },
{ label: "Sacola", value: "RiShoppingBagLine" },
{ label: "Cesta", value: "RiShoppingBasketLine" },
{ label: "Presente", value: "RiGiftLine" },
{ label: "Cupom", value: "RiCouponLine" },
{ label: "Ticket", value: "RiTicket2Line" },
// Alimentação
{ label: "Restaurante", value: "RiRestaurantLine" },
{ label: "Garfo e faca", value: "RiRestaurant2Line" },
{ label: "Café", value: "RiCupLine" },
{ label: "Bebida", value: "RiDrinksFill" },
{ label: "Pizza", value: "RiCake3Line" },
{ label: "Cerveja", value: "RiBeerLine" },
// Transporte
{ label: "Ônibus", value: "RiBusLine" },
{ label: "Carro", value: "RiCarLine" },
{ label: "Táxi", value: "RiTaxiLine" },
{ label: "Moto", value: "RiMotorbikeLine" },
{ label: "Avião", value: "RiFlightTakeoffLine" },
{ label: "Navio", value: "RiShipLine" },
{ label: "Trem", value: "RiTrainLine" },
{ label: "Metrô", value: "RiSubwayLine" },
{ label: "Bicicleta", value: "RiBikeLine" },
{ label: "Mapa", value: "RiMapPinLine" },
{ label: "Combustível", value: "RiGasStationLine" },
// Moradia
{ label: "Casa", value: "RiHomeLine" },
{ label: "Prédio", value: "RiBuilding2Line" },
{ label: "Apartamento", value: "RiBuildingLine" },
{ label: "Ferramentas", value: "RiToolsLine" },
{ label: "Lâmpada", value: "RiLightbulbLine" },
{ label: "Energia", value: "RiFlashlightLine" },
// Saúde e bem-estar
{ label: "Saúde", value: "RiStethoscopeLine" },
{ label: "Hospital", value: "RiHospitalLine" },
{ label: "Coração", value: "RiHeart2Line" },
{ label: "Pulso", value: "RiHeartPulseLine" },
{ label: "Mental", value: "RiMentalHealthLine" },
{ label: "Farmácia", value: "RiFirstAidKitLine" },
{ label: "Fitness", value: "RiRunLine" },
// Educação
{ label: "Livro", value: "RiBook2Line" },
{ label: "Graduação", value: "RiGraduationCapLine" },
{ label: "Escola", value: "RiSchoolLine" },
{ label: "Lápis", value: "RiPencilLine" },
// Trabalho
{ label: "Maleta", value: "RiBriefcaseLine" },
{ label: "Pasta", value: "RiBriefcase4Line" },
{ label: "Escritório", value: "RiUserStarLine" },
// Lazer
{ label: "Controle", value: "RiGamepadLine" },
{ label: "Filme", value: "RiMovie2Line" },
{ label: "Música", value: "RiMusic2Line" },
{ label: "Microfone", value: "RiMicLine" },
{ label: "Fone", value: "RiHeadphoneLine" },
{ label: "Câmera", value: "RiCameraLine" },
{ label: "Praia", value: "RiUmbrellaLine" },
{ label: "Futebol", value: "RiFootballLine" },
{ label: "Basquete", value: "RiBasketballLine" },
// Tecnologia
{ label: "WiFi", value: "RiWifiLine" },
{ label: "Celular", value: "RiSmartphoneLine" },
{ label: "Computador", value: "RiComputerLine" },
{ label: "Monitor", value: "RiMonitorLine" },
{ label: "Teclado", value: "RiKeyboardLine" },
{ label: "Mouse", value: "RiMouseLine" },
{ label: "Fone Bluetooth", value: "RiBluetoothLine" },
// Pessoas
{ label: "Usuário", value: "RiUserLine" },
{ label: "Grupo", value: "RiGroupLine" },
{ label: "Família", value: "RiParentLine" },
{ label: "Bebê", value: "RiBabyCarriageLine" },
// Animais
{ label: "Pet", value: "RiBearSmileLine" },
// Vestuário
{ label: "Camiseta", value: "RiTShirtLine" },
// Documentos
{ label: "Arquivo", value: "RiFileTextLine" },
{ label: "Documento", value: "RiArticleLine" },
{ label: "Balança", value: "RiScales2Line" },
{ label: "Escudo", value: "RiShieldCheckLine" },
// Serviços
{ label: "Serviço", value: "RiServiceLine" },
{ label: "Alerta", value: "RiAlertLine" },
{ label: "Troféu", value: "RiMedalLine" },
// Outros
{ label: "Mais", value: "RiMore2Line" },
{ label: "Estrela", value: "RiStarLine" },
{ label: "Foguete", value: "RiRocketLine" },
{ label: "Ampulheta", value: "RiHourglassLine" },
{ label: "Calendário", value: "RiCalendarLine" },
{ label: "Relógio", value: "RiTimeLine" },
{ label: "Timer", value: "RiTimer2Line" },
{ label: "Fogo", value: "RiFireLine" },
{ label: "Gota", value: "RiDropLine" },
{ label: "Sol", value: "RiSunLine" },
{ label: "Lua", value: "RiMoonLine" },
{ label: "Nuvem", value: "RiCloudLine" },
{ label: "Raio", value: "RiFlashlightFill" },
{ label: "Planta", value: "RiPlantLine" },
{ label: "Árvore", value: "RiSeedlingLine" },
{ label: "Globo", value: "RiGlobalLine" },
{ label: "Localização", value: "RiMapPin2Line" },
{ label: "Bússola", value: "RiCompassLine" },
{ label: "Reciclagem", value: "RiRecycleLine" },
{ label: "Cadeado", value: "RiLockLine" },
{ label: "Chave", value: "RiKeyLine" },
{ label: "Configurações", value: "RiSettings3Line" },
{ label: "Link", value: "RiLinkLine" },
{ label: "Anexo", value: "RiAttachmentLine" },
{ label: "Download", value: "RiDownloadLine" },
{ label: "Upload", value: "RiUploadLine" },
{ label: "Nuvem Download", value: "RiCloudDownloadLine" },
{ label: "Nuvem Upload", value: "RiCloudUploadLine" },
];
/**
* Gets all available category icon options
* @returns Array of icon options
*/
export function getCategoryIconOptions() {
return CATEGORY_ICON_OPTIONS;
}
/**
* Gets the default icon for a category type
* @returns Default icon value
*/
export function getDefaultIconForType() {
return CATEGORY_ICON_OPTIONS[0]?.value ?? "";
}

39
src/shared/lib/db.ts Normal file
View File

@@ -0,0 +1,39 @@
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "@/db/schema";
const globalForDb = globalThis as unknown as {
db?: NodePgDatabase<typeof schema>;
pool?: Pool;
};
let _db: NodePgDatabase<typeof schema> | undefined;
let _pool: Pool | undefined;
function getDb() {
if (_db) return _db;
const { DATABASE_URL } = process.env;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL env variable is not set");
}
_pool = globalForDb.pool ?? new Pool({ connectionString: DATABASE_URL });
_db = globalForDb.db ?? drizzle(_pool, { schema });
if (process.env.NODE_ENV !== "production") {
globalForDb.pool = _pool;
globalForDb.db = _db;
}
return _db;
}
export const db = new Proxy({} as NodePgDatabase<typeof schema>, {
get(_, prop) {
return Reflect.get(getDb(), prop);
},
});
export { schema };

View File

@@ -0,0 +1,16 @@
import { config } from "dotenv";
/**
* Endereço "from" para envio de e-mails via Resend.
* Lê RESEND_FROM_EMAIL do .env (valor deve estar entre aspas se tiver espaço:
* Garante carregamento do .env no contexto da chamada (ex.: Server Actions).
*/
const FALLBACK_FROM = "OpenMonetis <noreply@resend.dev>";
export function getResendFromEmail(): string {
// Garantir que .env foi carregado (não sobrescreve variáveis já definidas)
config({ path: ".env" });
const raw = process.env.RESEND_FROM_EMAIL;
const value = typeof raw === "string" ? raw.trim() : "";
return value.length > 0 ? value : FALLBACK_FROM;
}

View File

@@ -0,0 +1,68 @@
import type { EligibleInstallment } from "./anticipation-types";
/**
* Formata o resumo de parcelas antecipadas
* Exemplo: "Parcelas 1-3 de 12" ou "Parcela 5 de 12"
*/
export function formatAnticipatedInstallmentsRange(
installments: EligibleInstallment[],
): string {
const numbers = installments
.map((inst) => inst.currentInstallment)
.filter((num): num is number => num !== null)
.sort((a, b) => a - b);
if (numbers.length === 0) return "";
if (numbers.length === 1) {
const total = installments[0]?.installmentCount ?? 0;
return `Parcela ${numbers[0]} de ${total}`;
}
const first = numbers[0];
const last = numbers[numbers.length - 1];
const total = installments[0]?.installmentCount ?? 0;
// Se as parcelas são consecutivas
const isConsecutive = numbers.every((num, i) => {
if (i === 0) return true;
return num === (numbers[i - 1] ?? 0) + 1;
});
if (isConsecutive) {
return `Parcelas ${first}-${last} de ${total}`;
} else {
return `${numbers.length} parcelas de ${total}`;
}
}
/**
* Calcula quantas parcelas restam após uma antecipação
*/
export function calculateRemainingInstallments(
totalInstallments: number,
anticipatedCount: number,
): number {
return Math.max(0, totalInstallments - anticipatedCount);
}
/**
* Gera descrição automática para o lançamento de antecipação
*/
export function generateAnticipationDescription(
lancamentoName: string,
installmentCount: number,
): string {
return `Antecipação de ${installmentCount} ${
installmentCount === 1 ? "parcela" : "parcelas"
} - ${lancamentoName}`;
}
/**
* Formata nota automática para antecipação
*/
export function generateAnticipationNote(
installments: EligibleInstallment[],
): string {
const range = formatAnticipatedInstallmentsRange(installments);
return `Antecipação: ${range}`;
}

View File

@@ -0,0 +1,52 @@
import type {
AntecipacaoParcela,
Categoria,
Lancamento,
Pagador,
} from "@/db/schema";
/**
* Parcela elegível para antecipação
*/
export type EligibleInstallment = {
id: string;
name: string;
amount: string;
period: string;
purchaseDate: Date;
dueDate: Date | null;
currentInstallment: number | null;
installmentCount: number | null;
paymentMethod: string;
categoriaId: string | null;
pagadorId: string | null;
};
/**
* Antecipação com dados completos
*/
export type InstallmentAnticipationWithRelations = AntecipacaoParcela & {
lancamento: Lancamento;
pagador: Pagador | null;
categoria: Categoria | null;
};
/**
* Input para criar antecipação
*/
export type CreateAnticipationInput = {
seriesId: string;
installmentIds: string[];
anticipationPeriod: string;
discount?: number;
pagadorId?: string;
categoriaId?: string;
note?: string;
};
/**
* Input para cancelar antecipação
*/
export type CancelAnticipationInput = {
anticipationId: string;
};

View File

@@ -0,0 +1,70 @@
import { displayPeriod, periodToDate } from "@/shared/utils/period";
/**
* Calcula a data da última parcela baseado no período da parcela atual
* @param currentPeriod - Período da parcela atual no formato YYYY-MM (ex: "2025-11")
* @param currentInstallment - Número da parcela atual
* @param totalInstallments - Quantidade total de parcelas
* @returns Data da última parcela
*/
export function calculateLastInstallmentDate(
currentPeriod: string,
currentInstallment: number,
totalInstallments: number,
): Date {
let currentDate: Date;
try {
currentDate = periodToDate(currentPeriod);
} catch {
return new Date();
}
// Calcula quantas parcelas faltam (incluindo a atual)
// Ex: parcela 2 de 6 -> restam 5 parcelas (2, 3, 4, 5, 6)
const remainingInstallments = totalInstallments - currentInstallment + 1;
// Calcula quantos meses adicionar para chegar na última parcela
// Ex: restam 5 parcelas -> adicionar 4 meses (parcela atual + 4 = 5 parcelas)
const monthsToAdd = remainingInstallments - 1;
// Simplificando: monthsToAdd = totalInstallments - currentInstallment
currentDate.setMonth(currentDate.getMonth() + monthsToAdd);
return currentDate;
}
/**
* Formata a data da última parcela no formato "Mês de Ano"
* Exemplo: "Março de 2026"
*/
export function formatLastInstallmentDate(date: Date): string {
return displayPeriod(
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`,
);
}
/**
* Formata a data de compra no formato "dia, dd mmm"
* Exemplo: "qua, 24 set"
*/
export function formatPurchaseDate(date: Date): string {
const formatter = new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
month: "short",
timeZone: "UTC",
});
return formatter.format(date);
}
/**
* Formata o texto da parcela atual
* Exemplo: "1 de 6"
*/
export function formatCurrentInstallment(
current: number,
total: number,
): string {
return `${current} de ${total}`;
}

View File

@@ -0,0 +1,32 @@
export const INVOICE_PAYMENT_STATUS = {
PENDING: "pendente",
PAID: "pago",
} as const;
export const INVOICE_STATUS_VALUES = Object.values(INVOICE_PAYMENT_STATUS);
export type InvoicePaymentStatus =
(typeof INVOICE_PAYMENT_STATUS)[keyof typeof INVOICE_PAYMENT_STATUS];
export const INVOICE_STATUS_LABEL: Record<InvoicePaymentStatus, string> = {
[INVOICE_PAYMENT_STATUS.PENDING]: "Em aberto",
[INVOICE_PAYMENT_STATUS.PAID]: "Pago",
};
export const INVOICE_STATUS_BADGE_VARIANT: Record<
InvoicePaymentStatus,
"default" | "secondary" | "success" | "info"
> = {
[INVOICE_PAYMENT_STATUS.PENDING]: "info",
[INVOICE_PAYMENT_STATUS.PAID]: "success",
};
export const INVOICE_STATUS_DESCRIPTION: Record<InvoicePaymentStatus, string> =
{
[INVOICE_PAYMENT_STATUS.PENDING]:
"Esta fatura ainda não foi quitada. Você pode realizar o pagamento assim que revisar os lançamentos.",
[INVOICE_PAYMENT_STATUS.PAID]:
"Esta fatura está quitada. Caso tenha sido um engano, é possível desfazer o pagamento.",
};
export const PERIOD_FORMAT_REGEX = /^\d{4}-\d{2}$/;

View File

@@ -0,0 +1,71 @@
/**
* Logo utilities
*
* Consolidated from:
* - /lib/logo.ts (utility functions)
*/
/**
* Normalizes logo path to get just the filename
* @param logo - Logo path or URL
* @returns Filename only
*/
export const normalizeLogo = (logo?: string | null) =>
logo?.split("/").filter(Boolean).pop() ?? "";
/**
* Derives a display name from a logo filename
* @param logo - Logo path or filename
* @returns Formatted display name
* @example
* deriveNameFromLogo("my-company-logo.png") // "My Company Logo"
*/
export const deriveNameFromLogo = (logo?: string | null) => {
if (!logo) {
return "";
}
const fileName = normalizeLogo(logo);
if (!fileName) {
return "";
}
const withoutExtension = fileName.replace(/\.[^/.]+$/, "");
return withoutExtension
.split(/[-_.\s]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join(" ");
};
const LOGO_SRC_PATTERN = /^(https?:\/\/|data:)/;
type ResolveLogoSrcOptions = {
basePath?: string;
};
export const resolveLogoSrc = (
logo?: string | null,
options?: ResolveLogoSrcOptions,
) => {
if (!logo) {
return null;
}
if (LOGO_SRC_PATTERN.test(logo)) {
return logo;
}
if (logo.startsWith("/")) {
return logo;
}
const fileName = normalizeLogo(logo);
if (!fileName) {
return null;
}
const basePath = options?.basePath?.replace(/\/$/, "") || "/logos";
return `${basePath}/${fileName}`;
};

View File

@@ -0,0 +1,23 @@
import { readdir } from "node:fs/promises";
import path from "node:path";
const LOGOS_DIRECTORY = path.join(process.cwd(), "public", "logos");
const LOGO_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp"]);
/**
* Loads available logo files from the public/logos directory
* @returns Array of logo filenames sorted alphabetically
*/
export async function loadLogoOptions() {
try {
const files = await readdir(LOGOS_DIRECTORY, { withFileTypes: true });
return files
.filter((file) => file.isFile())
.map((file) => file.name)
.filter((file) => LOGO_EXTENSIONS.has(path.extname(file).toLowerCase()))
.sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" }));
} catch {
return [];
}
}

View File

@@ -0,0 +1,88 @@
import { and, eq } from "drizzle-orm";
import {
compartilhamentosPagador,
pagadores,
user as usersTable,
} from "@/db/schema";
import { db } from "@/shared/lib/db";
export type PagadorWithAccess = typeof pagadores.$inferSelect & {
canEdit: boolean;
sharedByName: string | null;
sharedByEmail: string | null;
shareId: string | null;
};
export async function fetchPagadoresWithAccess(
userId: string,
): Promise<PagadorWithAccess[]> {
const [owned, shared] = await Promise.all([
db.query.pagadores.findMany({
where: eq(pagadores.userId, userId),
}),
db
.select({
shareId: compartilhamentosPagador.id,
pagador: pagadores,
ownerName: usersTable.name,
ownerEmail: usersTable.email,
})
.from(compartilhamentosPagador)
.innerJoin(
pagadores,
eq(compartilhamentosPagador.pagadorId, pagadores.id),
)
.leftJoin(usersTable, eq(pagadores.userId, usersTable.id))
.where(eq(compartilhamentosPagador.sharedWithUserId, userId)),
]);
const ownedMapped: PagadorWithAccess[] = owned.map((item) => ({
...item,
canEdit: true,
sharedByName: null,
sharedByEmail: null,
shareId: null,
}));
const sharedMapped: PagadorWithAccess[] = shared.map((item) => ({
...item.pagador,
shareCode: null,
canEdit: false,
sharedByName: item.ownerName ?? null,
sharedByEmail: item.ownerEmail ?? null,
shareId: item.shareId,
}));
return [...ownedMapped, ...sharedMapped];
}
export async function getPagadorAccess(userId: string, pagadorId: string) {
const pagador = await db.query.pagadores.findFirst({
where: and(eq(pagadores.id, pagadorId)),
});
if (!pagador) {
return null;
}
if (pagador.userId === userId) {
return {
pagador,
canEdit: true,
share: null as typeof compartilhamentosPagador.$inferSelect | null,
};
}
const share = await db.query.compartilhamentosPagador.findFirst({
where: and(
eq(compartilhamentosPagador.pagadorId, pagadorId),
eq(compartilhamentosPagador.sharedWithUserId, userId),
),
});
if (!share) {
return null;
}
return { pagador, canEdit: false, share };
}

View File

@@ -0,0 +1,7 @@
export const PAGADOR_STATUS_OPTIONS = ["Ativo", "Inativo"] as const;
export type PagadorStatus = (typeof PAGADOR_STATUS_OPTIONS)[number];
export const PAGADOR_ROLE_ADMIN = "admin";
export const PAGADOR_ROLE_TERCEIRO = "terceiro";
export const DEFAULT_PAGADOR_AVATAR = "default_icon.png";

View File

@@ -0,0 +1,54 @@
import { eq } from "drizzle-orm";
import { pagadores } from "@/db/schema";
import { db } from "@/shared/lib/db";
import {
DEFAULT_PAGADOR_AVATAR,
PAGADOR_ROLE_ADMIN,
PAGADOR_STATUS_OPTIONS,
} from "./constants";
import { normalizeNameFromEmail } from "./utils";
const DEFAULT_STATUS = PAGADOR_STATUS_OPTIONS[0];
interface SeedUserLike {
id?: string;
name?: string | null;
email?: string | null;
image?: string | null;
}
export async function ensureDefaultPagadorForUser(user: SeedUserLike) {
const userId = user.id;
if (!userId) {
return;
}
const hasAnyPagador = await db.query.pagadores.findFirst({
columns: { id: true, role: true },
where: eq(pagadores.userId, userId),
});
if (hasAnyPagador) {
return;
}
const name =
(user.name && user.name.trim().length > 0
? user.name.trim()
: normalizeNameFromEmail(user.email)) || "Pagador principal";
// Usa a imagem do Google se disponível, senão usa o avatar padrão
const avatarUrl = user.image ?? DEFAULT_PAGADOR_AVATAR;
await db.insert(pagadores).values({
name,
email: user.email ?? null,
status: DEFAULT_STATUS,
role: PAGADOR_ROLE_ADMIN,
avatarUrl,
note: null,
isAutoSend: false,
userId,
});
}

View File

@@ -0,0 +1,379 @@
import {
and,
asc,
eq,
gte,
ilike,
isNull,
lte,
not,
or,
sql,
sum,
} from "drizzle-orm";
import { cartoes, lancamentos } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import { toDateOnlyString } from "@/shared/utils/date";
import { safeToNumber as toNumber } from "@/shared/utils/number";
import {
addMonthsToPeriod,
buildPeriodRange,
formatCompactPeriodLabel,
} from "@/shared/utils/period";
const RECEITA = "Receita";
const DESPESA = "Despesa";
const PAYMENT_METHOD_CARD = "Cartão de crédito";
const PAYMENT_METHOD_BOLETO = "Boleto";
export type PagadorMonthlyBreakdown = {
totalExpenses: number;
totalIncomes: number;
paymentSplits: Record<"card" | "boleto" | "instant", number>;
};
export type PagadorHistoryPoint = {
period: string;
label: string;
receitas: number;
despesas: number;
};
export type PagadorCardUsageItem = {
id: string;
name: string;
logo: string | null;
amount: number;
};
export type PagadorBoletoStats = {
totalAmount: number;
paidAmount: number;
pendingAmount: number;
paidCount: number;
pendingCount: number;
};
export type PagadorBoletoItem = {
id: string;
name: string;
amount: number;
dueDate: string | null;
boletoPaymentDate: string | null;
isSettled: boolean;
};
export type PagadorPaymentStatusData = {
paidAmount: number;
paidCount: number;
pendingAmount: number;
pendingCount: number;
totalAmount: number;
};
const excludeAutoInvoiceEntries = () =>
or(
isNull(lancamentos.note),
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
);
type BaseFilters = {
userId: string;
pagadorId: string;
period: string;
};
export async function fetchPagadorMonthlyBreakdown({
userId,
pagadorId,
period,
}: BaseFilters): Promise<PagadorMonthlyBreakdown> {
const rows = await db
.select({
paymentMethod: lancamentos.paymentMethod,
transactionType: lancamentos.transactionType,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period),
excludeAutoInvoiceEntries(),
),
)
.groupBy(lancamentos.paymentMethod, lancamentos.transactionType);
const paymentSplits: PagadorMonthlyBreakdown["paymentSplits"] = {
card: 0,
boleto: 0,
instant: 0,
};
let totalExpenses = 0;
let totalIncomes = 0;
for (const row of rows) {
const total = Math.abs(toNumber(row.totalAmount));
if (row.transactionType === DESPESA) {
totalExpenses += total;
if (row.paymentMethod === PAYMENT_METHOD_CARD) {
paymentSplits.card += total;
} else if (row.paymentMethod === PAYMENT_METHOD_BOLETO) {
paymentSplits.boleto += total;
} else {
paymentSplits.instant += total;
}
} else if (row.transactionType === RECEITA) {
totalIncomes += total;
}
}
return {
totalExpenses,
totalIncomes,
paymentSplits,
};
}
export async function fetchPagadorHistory({
userId,
pagadorId,
period,
months = 6,
}: BaseFilters & { months?: number }): Promise<PagadorHistoryPoint[]> {
const startPeriod = addMonthsToPeriod(period, -(Math.max(months, 1) - 1));
const windowPeriods = buildPeriodRange(startPeriod, period);
const start = windowPeriods[0];
const end = windowPeriods[windowPeriods.length - 1];
const rows = await db
.select({
period: lancamentos.period,
transactionType: lancamentos.transactionType,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, pagadorId),
gte(lancamentos.period, start),
lte(lancamentos.period, end),
excludeAutoInvoiceEntries(),
),
)
.groupBy(lancamentos.period, lancamentos.transactionType);
const totalsByPeriod = new Map<
string,
{ receitas: number; despesas: number }
>();
for (const key of windowPeriods) {
totalsByPeriod.set(key, { receitas: 0, despesas: 0 });
}
for (const row of rows) {
const key = row.period ?? undefined;
if (!key || !totalsByPeriod.has(key)) continue;
const bucket = totalsByPeriod.get(key);
if (!bucket) continue;
const total = Math.abs(toNumber(row.totalAmount));
if (row.transactionType === DESPESA) {
bucket.despesas += total;
} else if (row.transactionType === RECEITA) {
bucket.receitas += total;
}
}
return windowPeriods.map((key) => ({
period: key,
label: formatCompactPeriodLabel(key),
receitas: totalsByPeriod.get(key)?.receitas ?? 0,
despesas: totalsByPeriod.get(key)?.despesas ?? 0,
}));
}
export async function fetchPagadorCardUsage({
userId,
pagadorId,
period,
}: BaseFilters): Promise<PagadorCardUsageItem[]> {
const rows = await db
.select({
cartaoId: lancamentos.cartaoId,
cardName: cartoes.name,
cardLogo: cartoes.logo,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_CARD),
excludeAutoInvoiceEntries(),
),
)
.groupBy(lancamentos.cartaoId, cartoes.name, cartoes.logo);
const items: PagadorCardUsageItem[] = [];
for (const row of rows) {
if (!row.cartaoId) {
continue;
}
items.push({
id: row.cartaoId,
name: row.cardName ?? "Cartão",
logo: row.cardLogo ?? null,
amount: Math.abs(toNumber(row.totalAmount)),
});
}
return items.sort((a, b) => b.amount - a.amount);
}
export async function fetchPagadorBoletoStats({
userId,
pagadorId,
period,
}: BaseFilters): Promise<PagadorBoletoStats> {
const rows = await db
.select({
isSettled: lancamentos.isSettled,
totalAmount: sum(lancamentos.amount).as("total"),
totalCount: sql<number>`count(${lancamentos.id})`.as("count"),
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
excludeAutoInvoiceEntries(),
),
)
.groupBy(lancamentos.isSettled);
let paidAmount = 0;
let pendingAmount = 0;
let paidCount = 0;
let pendingCount = 0;
for (const row of rows) {
const total = Math.abs(toNumber(row.totalAmount));
const count = toNumber(row.totalCount);
if (row.isSettled) {
paidAmount += total;
paidCount += count;
} else {
pendingAmount += total;
pendingCount += count;
}
}
return {
totalAmount: paidAmount + pendingAmount,
paidAmount,
pendingAmount,
paidCount,
pendingCount,
};
}
export async function fetchPagadorBoletoItems({
userId,
pagadorId,
period,
}: BaseFilters): Promise<PagadorBoletoItem[]> {
const rows = await db
.select({
id: lancamentos.id,
name: lancamentos.name,
amount: lancamentos.amount,
dueDate: lancamentos.dueDate,
boletoPaymentDate: lancamentos.boletoPaymentDate,
isSettled: lancamentos.isSettled,
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
excludeAutoInvoiceEntries(),
),
)
.orderBy(asc(lancamentos.dueDate));
const items: PagadorBoletoItem[] = [];
for (const row of rows) {
items.push({
id: row.id,
name: row.name,
amount: Math.abs(toNumber(row.amount)),
dueDate: toDateOnlyString(row.dueDate),
boletoPaymentDate: toDateOnlyString(row.boletoPaymentDate),
isSettled: Boolean(row.isSettled),
});
}
return items;
}
export async function fetchPagadorPaymentStatus({
userId,
pagadorId,
period,
}: BaseFilters): Promise<PagadorPaymentStatusData> {
const rows = await db
.select({
paidAmount: sql<string>`coalesce(sum(case when ${lancamentos.isSettled} = true then abs(${lancamentos.amount}) else 0 end), 0)`,
paidCount: sql<number>`sum(case when ${lancamentos.isSettled} = true then 1 else 0 end)`,
pendingAmount: sql<string>`coalesce(sum(case when (${lancamentos.isSettled} = false or ${lancamentos.isSettled} is null) then abs(${lancamentos.amount}) else 0 end), 0)`,
pendingCount: sql<number>`sum(case when (${lancamentos.isSettled} = false or ${lancamentos.isSettled} is null) then 1 else 0 end)`,
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, DESPESA),
excludeAutoInvoiceEntries(),
),
);
const row = rows[0];
if (!row) {
return {
paidAmount: 0,
paidCount: 0,
pendingAmount: 0,
pendingCount: 0,
totalAmount: 0,
};
}
const paidAmount = toNumber(row.paidAmount);
const paidCount = toNumber(row.paidCount);
const pendingAmount = toNumber(row.pendingAmount);
const pendingCount = toNumber(row.pendingCount);
return {
paidAmount,
paidCount,
pendingAmount,
pendingCount,
totalAmount: paidAmount + pendingAmount,
};
}

View File

@@ -0,0 +1,25 @@
import { and, eq } from "drizzle-orm";
import { cache } from "react";
import { pagadores } from "@/db/schema";
import { db } from "@/shared/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
/**
* Returns the admin pagador ID for a user (cached per request via React.cache).
* Eliminates the need for JOIN with pagadores in ~20 dashboard queries.
*/
export const getAdminPagadorId = cache(
async (userId: string): Promise<string | null> => {
const [row] = await db
.select({ id: pagadores.id })
.from(pagadores)
.where(
and(
eq(pagadores.userId, userId),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
)
.limit(1);
return row?.id ?? null;
},
);

View File

@@ -0,0 +1,240 @@
import { inArray } from "drizzle-orm";
import { Resend } from "resend";
import { pagadores } from "@/db/schema";
import { db } from "@/shared/lib/db";
import { getResendFromEmail } from "@/shared/lib/email/resend";
import { formatCurrency } from "@/shared/utils/currency";
import { formatDateTime } from "@/shared/utils/date";
type ActionType = "created" | "deleted";
export type NotificationEntry = {
pagadorId: string;
name: string | null;
amount: number;
transactionType: string | null;
paymentMethod: string | null;
condition: string | null;
purchaseDate: Date | null;
period: string | null;
note: string | null;
};
export type PagadorNotificationRequest = {
userLabel: string;
action: ActionType;
entriesByPagador: Map<string, NotificationEntry[]>;
};
type PagadorNotificationRecipient = {
id: string;
name: string | null;
email: string | null;
isAutoSend: boolean | null;
};
const formatDate = (value: Date | null) => {
return (
formatDateTime(value, {
day: "2-digit",
month: "short",
year: "numeric",
}) ?? "—"
);
};
const buildHtmlBody = ({
userLabel,
action,
entries,
}: {
userLabel: string;
action: ActionType;
entries: NotificationEntry[];
}) => {
const actionLabel =
action === "created" ? "Novo lançamento registrado" : "Lançamento removido";
const actionDescription =
action === "created"
? "Um novo lançamento foi registrado em seu nome."
: "Um lançamento anteriormente registrado em seu nome foi removido.";
const rows = entries
.map((entry) => {
const label =
entry.transactionType === "Despesa"
? `${formatCurrency(Math.abs(entry.amount))} (Despesa)`
: `${formatCurrency(Math.abs(entry.amount))} (Receita)`;
return `<tr>
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${formatDate(
entry.purchaseDate,
)}</td>
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${
entry.name ?? "Sem descrição"
}</td>
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${
entry.paymentMethod ?? "—"
}</td>
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${
entry.condition ?? "—"
}</td>
<td style="padding:8px;border-bottom:1px solid #e2e8f0;text-align:right;">${label}</td>
</tr>`;
})
.join("");
return `
<div style="font-family:'Inter',Arial,sans-serif;color:#0f172a;line-height:1.5;">
<h2 style="margin:0 0 8px 0;font-size:20px;">${actionLabel}</h2>
<p style="margin:0 0 16px 0;color:#475569;">${actionDescription}</p>
<table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:16px;">
<thead>
<tr style="background:#f8fafc;">
<th style="text-align:left;padding:8px;border-bottom:1px solid #e2e8f0;">Data</th>
<th style="text-align:left;padding:8px;border-bottom:1px solid #e2e8f0;">Descrição</th>
<th style="text-align:left;padding:8px;border-bottom:1px solid #e2e8f0;">Pagamento</th>
<th style="text-align:left;padding:8px;border-bottom:1px solid #e2e8f0;">Condição</th>
<th style="text-align:right;padding:8px;border-bottom:1px solid #e2e8f0;">Valor</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
<p style="margin:0;font-size:12px;color:#94a3b8;">
Enviado automaticamente por ${userLabel} via OpenMonetis.
</p>
</div>
`;
};
export async function sendPagadorAutoEmails({
userLabel,
action,
entriesByPagador,
}: PagadorNotificationRequest) {
"use server";
if (entriesByPagador.size === 0) {
return;
}
const resendApiKey = process.env.RESEND_API_KEY;
const resendFrom = getResendFromEmail();
if (!resendApiKey) {
console.warn(
"RESEND_API_KEY não configurada. Envio automático de lançamentos ignorado.",
);
return;
}
const pagadorIds = Array.from(entriesByPagador.keys());
if (pagadorIds.length === 0) {
return;
}
const pagadorRows = (await db.query.pagadores.findMany({
where: inArray(pagadores.id, pagadorIds),
})) as PagadorNotificationRecipient[];
if (pagadorRows.length === 0) {
return;
}
const resend = new Resend(resendApiKey);
const subjectPrefix =
action === "created" ? "Novo lançamento" : "Lançamento removido";
const results = await Promise.allSettled(
pagadorRows.map(async (pagador: PagadorNotificationRecipient) => {
if (!pagador.email || !pagador.isAutoSend) {
return;
}
const entries = entriesByPagador.get(pagador.id);
if (!entries || entries.length === 0) {
return;
}
const html = buildHtmlBody({
userLabel,
action,
entries,
});
await resend.emails.send({
from: resendFrom,
to: pagador.email,
subject: `${subjectPrefix} - ${pagador.name}`,
html,
});
}),
);
// Log any failed email sends
results.forEach((result: PromiseSettledResult<void>, index: number) => {
if (result.status === "rejected") {
const pagador = pagadorRows[index];
console.error(
`Failed to send email notification to ${pagador?.name} (${pagador?.email}):`,
result.reason,
);
}
});
}
export type RawNotificationRecord = {
pagadorId: string | null;
name: string | null;
amount: string | number | null;
transactionType: string | null;
paymentMethod: string | null;
condition: string | null;
purchaseDate: Date | string | null;
period: string | null;
note: string | null;
};
export const buildEntriesByPagador = (
records: RawNotificationRecord[],
): Map<string, NotificationEntry[]> => {
const map = new Map<string, NotificationEntry[]>();
records.forEach((record) => {
if (!record.pagadorId) {
return;
}
const amount =
typeof record.amount === "number"
? record.amount
: Number(record.amount ?? 0);
const purchaseDate =
record.purchaseDate instanceof Date
? record.purchaseDate
: record.purchaseDate
? new Date(record.purchaseDate)
: null;
const entry: NotificationEntry = {
pagadorId: record.pagadorId,
name: record.name ?? null,
amount,
transactionType: record.transactionType ?? null,
paymentMethod: record.paymentMethod ?? null,
condition: record.condition ?? null,
purchaseDate,
period: record.period ?? null,
note: record.note ?? null,
};
const list = map.get(record.pagadorId) ?? [];
list.push(entry);
map.set(record.pagadorId, list);
});
return map;
};

View File

@@ -0,0 +1,70 @@
import { DEFAULT_PAGADOR_AVATAR } from "./constants";
/**
* Normaliza o caminho do avatar extraindo apenas o nome do arquivo.
* Remove qualquer caminho anterior e retorna null se não houver avatar.
* Preserva URLs completas (http/https/data).
*/
export const normalizeAvatarPath = (
avatar: string | null | undefined,
): string | null => {
if (!avatar) return null;
// Preservar URLs completas (Google, etc)
if (
avatar.startsWith("http://") ||
avatar.startsWith("https://") ||
avatar.startsWith("data:")
) {
return avatar;
}
const file = avatar.split("/").filter(Boolean).pop();
return file ?? avatar;
};
/**
* Retorna o caminho completo para o avatar, com fallback para o avatar padrão.
* Se o avatar for uma URL completa (http/https/data), retorna diretamente.
*/
export const getAvatarSrc = (avatar: string | null | undefined): string => {
if (!avatar) {
return `/avatars/${DEFAULT_PAGADOR_AVATAR}`;
}
// Se for uma URL completa (Google, etc), retorna diretamente
if (
avatar.startsWith("http://") ||
avatar.startsWith("https://") ||
avatar.startsWith("data:")
) {
return avatar;
}
// Se for um caminho local, normaliza e adiciona o prefixo
const normalized = normalizeAvatarPath(avatar);
return `/avatars/${normalized ?? DEFAULT_PAGADOR_AVATAR}`;
};
/**
* Normaliza nome a partir de email
*/
export const normalizeNameFromEmail = (
email: string | null | undefined,
): string => {
if (!email) {
return "Novo pagador";
}
const [local] = email.split("@");
if (!local) {
return "Novo pagador";
}
return local
.split(".")
.map((segment) =>
segment.length > 0
? segment[0]?.toUpperCase().concat(segment.slice(1))
: segment,
)
.join(" ");
};

View File

@@ -0,0 +1,38 @@
import { eq } from "drizzle-orm";
import { cache } from "react";
import {
DEFAULT_FONT_KEY,
type FontKey,
normalizeFontKey,
} from "@/public/fonts/font_index";
import { db, schema } from "@/shared/lib/db";
export type FontPreferences = {
systemFont: FontKey;
moneyFont: FontKey;
};
const DEFAULT_FONT_PREFS: FontPreferences = {
systemFont: DEFAULT_FONT_KEY,
moneyFont: DEFAULT_FONT_KEY,
};
export const fetchUserFontPreferences = cache(
async (userId: string): Promise<FontPreferences> => {
const result = await db
.select({
systemFont: schema.preferenciasUsuario.systemFont,
moneyFont: schema.preferenciasUsuario.moneyFont,
})
.from(schema.preferenciasUsuario)
.where(eq(schema.preferenciasUsuario.userId, userId))
.limit(1);
if (!result[0]) return DEFAULT_FONT_PREFS;
return {
systemFont: normalizeFontKey(result[0].systemFont),
moneyFont: normalizeFontKey(result[0].moneyFont),
};
},
);

View File

@@ -0,0 +1,59 @@
import { z } from "zod";
/**
* Common Zod schemas for reuse across the application
*/
/**
* UUID schema with custom error message
*/
export const uuidSchema = (entityName: string = "ID") =>
z
.string({ message: `${entityName} inválido.` })
.uuid(`${entityName} inválido.`);
/**
* Optional/nullable decimal string schema
*/
export const optionalDecimalSchema = z
.string()
.trim()
.optional()
.transform((value) =>
value && value.length > 0 ? value.replace(",", ".") : null,
)
.refine(
(value) => value === null || !Number.isNaN(Number.parseFloat(value)),
"Informe um valor numérico válido.",
)
.transform((value) => (value === null ? null : Number.parseFloat(value)));
/**
* Day of month schema (1-31)
*/
export const dayOfMonthSchema = z
.string({ message: "Informe o dia." })
.trim()
.min(1, "Informe o dia.")
.refine((value) => {
const parsed = Number.parseInt(value, 10);
return !Number.isNaN(parsed) && parsed >= 1 && parsed <= 31;
}, "Informe um dia entre 1 e 31.");
/**
* Period schema (YYYY-MM format)
*/
export const periodSchema = z
.string({ message: "Informe o período." })
.trim()
.regex(/^\d{4}-(0[1-9]|1[0-2])$/, "Período inválido.");
/**
* Note/observation schema (max 500 chars, trimmed, nullable)
*/
export const noteSchema = z
.string()
.trim()
.max(500, "A anotação deve ter no máximo 500 caracteres.")
.optional()
.transform((value) => (value && value.length > 0 ? value : null));

View File

@@ -0,0 +1,16 @@
import { z } from "zod";
export const inboxItemSchema = z.object({
sourceApp: z.string().min(1, "sourceApp é obrigatório"),
sourceAppName: z.string().optional(),
originalTitle: z.string().optional(),
originalText: z.string().min(1, "originalText é obrigatório"),
notificationTimestamp: z.string().transform((val) => new Date(val)),
parsedName: z.string().optional(),
parsedAmount: z.coerce.number().optional(),
clientId: z.string().optional(), // ID local do app para rastreamento
});
export const inboxBatchSchema = z.object({
items: z.array(inboxItemSchema).min(1).max(50),
});

View File

@@ -0,0 +1,3 @@
export * from "./common";
export * from "./inbox";
export * from "./insights";

View File

@@ -0,0 +1,67 @@
import { z } from "zod";
/**
* Categorias de insights
*/
export const INSIGHT_CATEGORIES = {
behaviors: {
id: "behaviors",
title: "Comportamentos Observados",
icon: "RiEyeLine",
color: "blue",
},
triggers: {
id: "triggers",
title: "Gatilhos de Consumo",
icon: "RiFlashlightLine",
color: "amber",
},
recommendations: {
id: "recommendations",
title: "Recomendações Práticas",
icon: "RiLightbulbLine",
color: "green",
},
improvements: {
id: "improvements",
title: "Melhorias Sugeridas",
icon: "RiRocketLine",
color: "purple",
},
} as const;
export type InsightCategoryId = keyof typeof INSIGHT_CATEGORIES;
/**
* Schema para item individual de insight
*/
export const InsightItemSchema = z.object({
text: z.string().min(1),
});
/**
* Schema para categoria de insights
*/
export const InsightCategorySchema = z.object({
category: z.enum([
"behaviors",
"triggers",
"recommendations",
"improvements",
]),
items: z.array(InsightItemSchema).min(1).max(6),
});
/**
* Schema for complete insights response from AI
*/
export const InsightsResponseSchema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/), // YYYY-MM
generatedAt: z.string(), // ISO datetime
categories: z.array(InsightCategorySchema).length(4),
});
/**
* TypeScript types derived from schemas
*/
export type InsightsResponse = z.infer<typeof InsightsResponseSchema>;

View File

@@ -0,0 +1,13 @@
import { z } from "zod";
import { uuidSchema } from "./common";
/**
* Schema for pause/resume/cancel recurring series actions
*/
export const recurringSeriesActionSchema = z.object({
seriesId: uuidSchema("Série recorrente"),
});
export type RecurringSeriesActionInput = z.infer<
typeof recurringSeriesActionSchema
>;

View File

@@ -0,0 +1,5 @@
export const TRANSFER_CATEGORY_NAME = "Transferência interna";
export const TRANSFER_ESTABLISHMENT_SAIDA = "Saída - Transf. entre contas";
export const TRANSFER_ESTABLISHMENT_ENTRADA = "Entrada - Transf. entre contas";
export const TRANSFER_PAYMENT_METHOD = "Pix";
export const TRANSFER_CONDITION = "À vista";

View File

@@ -0,0 +1,13 @@
/**
* Standard action result type
*/
export type ActionResult<TData = void> =
| { success: true; message: string; data?: TData }
| { success: false; error: string };
/**
* Error result helper
*/
export function errorResult(error: string): ActionResult {
return { success: false, error };
}

View File

@@ -0,0 +1,62 @@
import type {
LancamentoItem,
SelectOption,
} from "@/features/transactions/components/types";
export type CalendarEvent =
| {
id: string;
type: "lancamento";
date: string;
lancamento: LancamentoItem;
}
| {
id: string;
type: "boleto";
date: string;
lancamento: LancamentoItem;
}
| {
id: string;
type: "cartao";
date: string;
card: {
id: string;
name: string;
dueDay: string;
closingDay: string;
brand: string | null;
status: string;
logo: string | null;
totalDue: number | null;
};
};
export type CalendarPeriod = {
period: string;
monthName: string;
year: number;
};
export type CalendarDay = {
date: string;
label: string;
isCurrentMonth: boolean;
isToday: boolean;
events: CalendarEvent[];
};
export type CalendarFormOptions = {
pagadorOptions: SelectOption[];
splitPagadorOptions: SelectOption[];
defaultPagadorId: string | null;
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[];
estabelecimentos: string[];
};
export type CalendarData = {
events: CalendarEvent[];
formOptions: CalendarFormOptions;
};

View File

@@ -0,0 +1,3 @@
export * from "./actions";
export * from "./calendar";
export * from "./reports";

View File

@@ -0,0 +1,52 @@
/**
* Types for Category Report feature
*/
/**
* Monthly data for a specific category in a specific period
*/
export type MonthlyData = {
period: string; // Format: "YYYY-MM"
amount: number; // Total amount for this category in this period
previousAmount: number; // Amount from previous period (for comparison)
percentageChange: number | null; // Percentage change from previous period
};
/**
* Single category item in the report
*/
export type CategoryReportItem = {
categoryId: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
monthlyData: Map<string, MonthlyData>; // Key: period (YYYY-MM)
total: number; // Total across all periods
};
/**
* Complete category report data structure
*/
export type CategoryReportData = {
categories: CategoryReportItem[]; // All categories with their data
periods: string[]; // All periods in the report (sorted chronologically)
totals: Map<string, number>; // Total per period across all categories
grandTotal: number; // Total of all categories and all periods
};
/**
* Filters for category report query
*/
export type CategoryReportFilters = {
startPeriod: string; // Format: "YYYY-MM"
endPeriod: string; // Format: "YYYY-MM"
categoryIds?: string[]; // Optional: filter by specific categories
};
/**
* Validation result for date range
*/
export type DateRangeValidation = {
isValid: boolean;
error?: string;
};