diff --git a/src/features/transactions/actions.ts b/src/features/transactions/actions.ts index 582e4d3..a40d3d6 100644 --- a/src/features/transactions/actions.ts +++ b/src/features/transactions/actions.ts @@ -6,6 +6,8 @@ export { } from "./actions/bulk-actions"; export { exportTransactionsDataAction } from "./actions/export-actions"; export { + convertTransactionToInstallmentAction, + convertTransactionToRecurringAction, createTransactionAction, deleteTransactionAction, toggleTransactionSettlementAction, diff --git a/src/features/transactions/actions/core.ts b/src/features/transactions/actions/core.ts index 080a269..e1c65d9 100644 --- a/src/features/transactions/actions/core.ts +++ b/src/features/transactions/actions/core.ts @@ -513,11 +513,28 @@ export const toggleSettlementSchema = z.object({ .optional(), }); +export const convertToInstallmentSchema = z.object({ + id: uuidSchema("Lançamento"), +}); + +export const convertToRecurringSchema = z.object({ + id: uuidSchema("Lançamento"), + recurrenceCount: z.coerce + .number({ message: "Informe por quantos meses repetir." }) + .int() + .min(2, "A recorrência deve ter ao menos dois meses.") + .max(60, "Selecione até 60 meses."), +}); + type BaseInput = z.infer; export type CreateInput = z.infer; export type UpdateInput = z.infer; export type DeleteInput = z.infer; export type ToggleSettlementInput = z.infer; +export type ConvertToInstallmentInput = z.infer< + typeof convertToInstallmentSchema +>; +export type ConvertToRecurringInput = z.infer; export const revalidate = (userId: string) => revalidateForEntity("transactions", userId); diff --git a/src/features/transactions/actions/single-actions.ts b/src/features/transactions/actions/single-actions.ts index d975154..b435c53 100644 --- a/src/features/transactions/actions/single-actions.ts +++ b/src/features/transactions/actions/single-actions.ts @@ -23,12 +23,17 @@ import { parseLocalDateString, } from "@/shared/utils/date"; import { copyAttachmentsForImport } from "../lib/attachment-copy"; +import { detectInstallmentFromName } from "../lib/installment-detection"; import { cleanupAttachmentsAfterTransactionDelete } from "./attachments"; import { buildShares, buildTransactionRecords, + type ConvertToInstallmentInput, + type ConvertToRecurringInput, type CreateInput, centsToDecimalString, + convertToInstallmentSchema, + convertToRecurringSchema, createSchema, type DeleteInput, deleteSchema, @@ -471,6 +476,330 @@ export async function deleteTransactionAction( } } +export async function convertTransactionToInstallmentAction( + input: ConvertToInstallmentInput, +): Promise> { + try { + const user = await getUser(); + const data = convertToInstallmentSchema.parse(input); + + const existing = await db.query.transactions.findFirst({ + where: and(eq(transactions.id, data.id), eq(transactions.userId, user.id)), + }); + + if (!existing) { + return { success: false, error: "Lançamento não encontrado." }; + } + + if (existing.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) { + return { + success: false, + error: "Pagamentos automáticos de fatura não podem ser convertidos.", + }; + } + + if (isInitialBalanceTransaction(existing)) { + return { + success: false, + error: "Lançamentos de saldo inicial não podem ser convertidos.", + }; + } + + if ( + existing.paymentMethod !== "Cartão de crédito" || + !existing.cardId || + existing.condition !== "À vista" + ) { + return { + success: false, + error: "Apenas lançamentos à vista de cartão de crédito podem ser convertidos.", + }; + } + + if (existing.splitGroupId || existing.isDivided) { + return { + success: false, + error: "Lançamentos divididos ainda não podem ser convertidos em parcelamento.", + }; + } + + const detected = detectInstallmentFromName(existing.name); + if (!detected) { + return { + success: false, + error: + "Não encontrei um padrão de parcela no nome deste lançamento, como 2/10 ou Parcela 2 de 10.", + }; + } + + const amountSign: 1 | -1 = + existing.transactionType === "Despesa" ? -1 : 1; + const totalCents = + Math.round(Math.abs(Number(existing.amount)) * 100) * + detected.installmentCount; + const seriesId = randomUUID(); + const records = buildTransactionRecords({ + data: { + purchaseDate: existing.purchaseDate.toISOString().slice(0, 10), + period: existing.period, + name: detected.name, + transactionType: existing.transactionType as "Receita" | "Despesa", + amount: totalCents / 100, + condition: "Parcelado", + paymentMethod: "Cartão de crédito", + payerId: existing.payerId, + isSplit: false, + accountId: null, + cardId: existing.cardId, + categoryId: existing.categoryId, + note: existing.note, + installmentCount: detected.installmentCount, + startInstallment: detected.currentInstallment, + dueDate: existing.dueDate?.toISOString().slice(0, 10), + isSettled: null, + }, + userId: user.id, + period: existing.period, + purchaseDate: existing.purchaseDate, + dueDate: existing.dueDate, + boletoPaymentDate: null, + shares: [{ payerId: existing.payerId, amountCents: totalCents }], + amountSign, + shouldNullifySettled: true, + seriesId, + }).map((record) => ({ + ...record, + importBatchId: existing.importBatchId, + })); + + const currentRow = records[0]; + const rowsToInsert = records.slice(1); + if (!currentRow) { + throw new Error("Não foi possível montar o parcelamento."); + } + + const periodsToUpdate = records + .map((row) => row.period) + .filter((period): period is string => Boolean(period)); + const paidPeriods = await getPaidInvoicePeriods( + user.id, + existing.cardId, + periodsToUpdate, + ); + + if (paidPeriods.length > 0) { + return { + success: false, + error: `As faturas dos meses ${formatPaidInvoicePeriods( + paidPeriods, + )} já estão pagas. Desfaça o pagamento antes de converter este lançamento.`, + }; + } + + if (existing.transactionType === "Despesa") { + const limitCheck = await validateCardLimit({ + userId: user.id, + cardId: existing.cardId, + addAmount: records.reduce((acc, row) => acc + Math.abs(Number(row.amount)), 0), + excludeTransactionIds: [existing.id], + }); + + if (!limitCheck.ok) { + return { success: false, error: limitCheck.error }; + } + } + + await db.transaction(async (tx: typeof db) => { + await tx + .update(transactions) + .set({ + condition: currentRow.condition, + name: currentRow.name, + amount: currentRow.amount, + installmentCount: currentRow.installmentCount, + currentInstallment: currentRow.currentInstallment, + recurrenceCount: null, + period: currentRow.period, + dueDate: currentRow.dueDate, + isSettled: null, + seriesId, + }) + .where( + and(eq(transactions.id, existing.id), eq(transactions.userId, user.id)), + ); + + if (rowsToInsert.length > 0) { + await tx.insert(transactions).values(rowsToInsert); + } + }); + + revalidate(user.id); + + return { + success: true, + message: `Lançamento convertido em ${detected.installmentCount} parcelas.`, + data: { createdCount: rowsToInsert.length }, + }; + } catch (error) { + return handleActionError(error) as ActionResult<{ createdCount: number }>; + } +} + +export async function convertTransactionToRecurringAction( + input: ConvertToRecurringInput, +): Promise> { + try { + const user = await getUser(); + const data = convertToRecurringSchema.parse(input); + + const existing = await db.query.transactions.findFirst({ + where: and(eq(transactions.id, data.id), eq(transactions.userId, user.id)), + }); + + if (!existing) { + return { success: false, error: "Lançamento não encontrado." }; + } + + if (existing.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) { + return { + success: false, + error: "Pagamentos automáticos de fatura não podem ser convertidos.", + }; + } + + if (isInitialBalanceTransaction(existing)) { + return { + success: false, + error: "Lançamentos de saldo inicial não podem ser convertidos.", + }; + } + + if (existing.condition !== "À vista") { + return { + success: false, + error: "Apenas lançamentos à vista podem ser convertidos em recorrência.", + }; + } + + if (existing.splitGroupId || existing.isDivided) { + return { + success: false, + error: "Lançamentos divididos ainda não podem ser convertidos em recorrência.", + }; + } + + const amountSign: 1 | -1 = + existing.transactionType === "Despesa" ? -1 : 1; + const totalCents = Math.round(Math.abs(Number(existing.amount)) * 100); + const seriesId = randomUUID(); + const isCreditCard = existing.paymentMethod === "Cartão de crédito"; + const records = buildTransactionRecords({ + data: { + purchaseDate: existing.purchaseDate.toISOString().slice(0, 10), + period: existing.period, + name: existing.name, + transactionType: existing.transactionType as "Receita" | "Despesa", + amount: totalCents / 100, + condition: "Recorrente", + paymentMethod: existing.paymentMethod as + | "Pix" + | "Boleto" + | "Dinheiro" + | "Cartão de débito" + | "Cartão de crédito" + | "Pré-Pago | VR/VA" + | "Transferência bancária", + payerId: existing.payerId, + isSplit: false, + accountId: isCreditCard ? null : existing.accountId, + cardId: isCreditCard ? existing.cardId : null, + categoryId: existing.categoryId, + note: existing.note, + recurrenceCount: data.recurrenceCount, + dueDate: existing.dueDate?.toISOString().slice(0, 10), + boletoPaymentDate: existing.boletoPaymentDate + ?.toISOString() + .slice(0, 10), + isSettled: existing.isSettled, + }, + userId: user.id, + period: existing.period, + purchaseDate: existing.purchaseDate, + dueDate: existing.dueDate, + boletoPaymentDate: existing.boletoPaymentDate, + shares: [{ payerId: existing.payerId, amountCents: totalCents }], + amountSign, + shouldNullifySettled: isCreditCard, + seriesId, + }).map((record) => ({ + ...record, + importBatchId: existing.importBatchId, + })); + + const currentRow = records[0]; + const rowsToInsert = records.slice(1); + if (!currentRow) { + throw new Error("Não foi possível montar a recorrência."); + } + + if (isCreditCard && existing.cardId) { + const periodsToUpdate = records + .map((row) => row.period) + .filter((period): period is string => Boolean(period)); + const paidPeriods = await getPaidInvoicePeriods( + user.id, + existing.cardId, + periodsToUpdate, + ); + + if (paidPeriods.length > 0) { + return { + success: false, + error: `As faturas dos meses ${formatPaidInvoicePeriods( + paidPeriods, + )} já estão pagas. Desfaça o pagamento antes de converter este lançamento.`, + }; + } + } + + await db.transaction(async (tx: typeof db) => { + await tx + .update(transactions) + .set({ + condition: currentRow.condition, + name: currentRow.name, + amount: currentRow.amount, + recurrenceCount: currentRow.recurrenceCount, + installmentCount: null, + currentInstallment: null, + period: currentRow.period, + purchaseDate: currentRow.purchaseDate, + dueDate: currentRow.dueDate, + isSettled: currentRow.isSettled, + boletoPaymentDate: currentRow.boletoPaymentDate, + seriesId, + }) + .where( + and(eq(transactions.id, existing.id), eq(transactions.userId, user.id)), + ); + + if (rowsToInsert.length > 0) { + await tx.insert(transactions).values(rowsToInsert); + } + }); + + revalidate(user.id); + + return { + success: true, + message: `Lançamento convertido em recorrência de ${data.recurrenceCount} meses.`, + data: { createdCount: rowsToInsert.length }, + }; + } catch (error) { + return handleActionError(error) as ActionResult<{ createdCount: number }>; + } +} + export async function updateTransactionSplitPairAction( input: UpdateInput, ): Promise { diff --git a/src/features/transactions/components/page/transactions-page.tsx b/src/features/transactions/components/page/transactions-page.tsx index ee527a3..5fc5eaa 100644 --- a/src/features/transactions/components/page/transactions-page.tsx +++ b/src/features/transactions/components/page/transactions-page.tsx @@ -4,6 +4,8 @@ import { RiAddFill } from "@remixicon/react"; import { useState } from "react"; import { toast } from "sonner"; import { + convertTransactionToInstallmentAction, + convertTransactionToRecurringAction, createMassTransactionsAction, deleteMultipleTransactionsAction, deleteTransactionAction, @@ -18,8 +20,19 @@ import { detachAttachmentBulkAction, getPresignedUploadUrlAction, } from "@/features/transactions/actions/attachments"; +import { detectInstallmentFromName } from "@/features/transactions/lib/installment-detection"; import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog"; import { Button } from "@/shared/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/components/ui/dialog"; +import { Input } from "@/shared/components/ui/input"; +import { Label } from "@/shared/components/ui/label"; import type { TransactionsExportContext, TransactionsPaginationState, @@ -190,6 +203,14 @@ export function TransactionsPage({ const [refundOpen, setRefundOpen] = useState(false); const [transactionToRefund, setTransactionToRefund] = useState(null); + const [convertInstallmentOpen, setConvertInstallmentOpen] = useState(false); + const [transactionToConvert, setTransactionToConvert] = + useState(null); + const [convertRecurringOpen, setConvertRecurringOpen] = useState(false); + const [transactionToConvertRecurring, setTransactionToConvertRecurring] = + useState(null); + const [recurrenceCount, setRecurrenceCount] = useState("12"); + const [recurrencePending, setRecurrencePending] = useState(false); const handleToggleSettlement = async (item: TransactionItem) => { if (item.paymentMethod === "Cartão de crédito") { @@ -542,6 +563,71 @@ export function TransactionsPage({ setRefundOpen(true); }; + const handleConvertToInstallment = (item: TransactionItem) => { + setTransactionToConvert(item); + setConvertInstallmentOpen(true); + }; + + const confirmConvertToInstallment = async () => { + if (!transactionToConvert) { + return; + } + + const result = await convertTransactionToInstallmentAction({ + id: transactionToConvert.id, + }); + + if (!result.success) { + toast.error(result.error); + throw new Error(result.error); + } + + toast.success(result.message); + setConvertInstallmentOpen(false); + setTransactionToConvert(null); + }; + + const conversionPreview = transactionToConvert + ? detectInstallmentFromName(transactionToConvert.name) + : null; + + const handleConvertToRecurring = (item: TransactionItem) => { + setTransactionToConvertRecurring(item); + setRecurrenceCount("12"); + setConvertRecurringOpen(true); + }; + + const confirmConvertToRecurring = async () => { + if (!transactionToConvertRecurring) { + return; + } + + const count = Number(recurrenceCount); + if (!Number.isInteger(count) || count < 2 || count > 60) { + toast.error("Informe uma recorrência entre 2 e 60 meses."); + return; + } + + try { + setRecurrencePending(true); + const result = await convertTransactionToRecurringAction({ + id: transactionToConvertRecurring.id, + recurrenceCount: count, + }); + + if (!result.success) { + toast.error(result.error); + return; + } + + toast.success(result.message); + setConvertRecurringOpen(false); + setTransactionToConvertRecurring(null); + } finally { + setRecurrencePending(false); + } + }; + const handleAnticipate = (item: TransactionItem) => { setSelectedForAnticipation(item); setAnticipateOpen(true); @@ -628,6 +714,8 @@ export function TransactionsPage({ onBulkImport={handleBulkImport} onViewDetails={handleViewDetails} onRefund={handleRefund} + onConvertToInstallment={handleConvertToInstallment} + onConvertToRecurring={handleConvertToRecurring} onToggleSettlement={handleToggleSettlement} onAnticipate={handleAnticipate} onViewAnticipationHistory={handleViewAnticipationHistory} @@ -749,6 +837,79 @@ export function TransactionsPage({ disabled={!transactionToDelete} /> + { + setConvertInstallmentOpen(open); + if (!open) { + setTransactionToConvert(null); + } + }} + title="Converter em parcelamento?" + description={ + conversionPreview + ? `Este lançamento será convertido para "${conversionPreview.name}" como parcela ${conversionPreview.currentInstallment} de ${conversionPreview.installmentCount}. As próximas parcelas serão criadas automaticamente.` + : "Este lançamento será convertido em uma série parcelada." + } + confirmLabel="Converter" + pendingLabel="Convertendo..." + onConfirm={confirmConvertToInstallment} + disabled={!transactionToConvert} + /> + + { + setConvertRecurringOpen(open); + if (!open) { + setTransactionToConvertRecurring(null); + } + }} + > + + + Converter em recorrente? + + O lançamento atual será mantido como a primeira recorrência e os + próximos meses serão criados automaticamente. + + + +
+ + setRecurrenceCount(event.target.value)} + /> +

+ Use o total de meses da série, incluindo este lançamento. +

+
+ + + + + +
+
+ void; onAnticipate?: (item: TransactionItem) => void; onViewAnticipationHistory?: (item: TransactionItem) => void; + onConvertToInstallment?: (item: TransactionItem) => void; + onConvertToRecurring?: (item: TransactionItem) => void; }; export function TransactionActionsMenu({ @@ -46,6 +51,8 @@ export function TransactionActionsMenu({ onRefund, onAnticipate, onViewAnticipationHistory, + onConvertToInstallment, + onConvertToRecurring, }: TransactionActionsMenuProps) { const isOwnData = item.userId === currentUserId; const canRefund = @@ -55,9 +62,29 @@ export function TransactionActionsMenu({ !item.splitGroupId && !item.readonly && !item.note?.startsWith(REFUND_NOTE_PREFIX); + const showInstallmentActions = isOwnData && item.condition === "Parcelado" && item.seriesId; + const detectedInstallment = detectInstallmentFromName(item.name); + const canConvertToInstallment = + isOwnData && + item.paymentMethod === CREDIT_CARD_PAYMENT_METHOD && + item.condition === "À vista" && + !item.splitGroupId && + !item.isDivided && + !item.readonly && + Boolean(detectedInstallment) && + Boolean(onConvertToInstallment); + + const canConvertToRecurring = + isOwnData && + item.condition === "À vista" && + !item.splitGroupId && + !item.isDivided && + !item.readonly && + Boolean(onConvertToRecurring); + return ( @@ -112,6 +139,24 @@ export function TransactionActionsMenu({ ) : null} + {canConvertToInstallment ? ( + onConvertToInstallment?.(item)} + > + {getConditionIcon("Parcelado")} + Converter em Parcelamento + + ) : null} + + {canConvertToRecurring ? ( + onConvertToRecurring?.(item)} + > + {getConditionIcon("Recorrente")} + Converter em Recorrente + + ) : null} + {isOwnData ? ( void; onAnticipate?: (item: TransactionItem) => void; onViewAnticipationHistory?: (item: TransactionItem) => void; + onConvertToInstallment?: (item: TransactionItem) => void; + onConvertToRecurring?: (item: TransactionItem) => void; isSettlementLoading: (id: string) => boolean; showActions: boolean; columnOrder?: string[] | null; @@ -109,6 +111,8 @@ function buildColumns({ onToggleSettlement, onAnticipate, onViewAnticipationHistory, + onConvertToInstallment, + onConvertToRecurring, isSettlementLoading, showActions, }: BuildColumnsArgs): ColumnDef[] { @@ -122,6 +126,8 @@ function buildColumns({ const handleToggleSettlement = onToggleSettlement ?? noop; const handleAnticipate = onAnticipate ?? noop; const handleViewAnticipationHistory = onViewAnticipationHistory ?? noop; + const handleConvertToInstallment = onConvertToInstallment ?? noop; + const handleConvertToRecurring = onConvertToRecurring ?? noop; const columns: ColumnDef[] = [ { @@ -545,6 +551,8 @@ function buildColumns({ onRefund={handleRefund} onAnticipate={handleAnticipate} onViewAnticipationHistory={handleViewAnticipationHistory} + onConvertToInstallment={onConvertToInstallment ? handleConvertToInstallment : undefined} + onConvertToRecurring={onConvertToRecurring ? handleConvertToRecurring : undefined} /> ), diff --git a/src/features/transactions/components/table/transactions-mobile-list.tsx b/src/features/transactions/components/table/transactions-mobile-list.tsx index 9e67007..6095c36 100644 --- a/src/features/transactions/components/table/transactions-mobile-list.tsx +++ b/src/features/transactions/components/table/transactions-mobile-list.tsx @@ -39,6 +39,8 @@ type TransactionsMobileListProps = { onToggleSettlement?: (item: TransactionItem) => void; onAnticipate?: (item: TransactionItem) => void; onViewAnticipationHistory?: (item: TransactionItem) => void; + onConvertToInstallment?: (item: TransactionItem) => void; + onConvertToRecurring?: (item: TransactionItem) => void; isSettlementLoading: (id: string) => boolean; showActions?: boolean; }; @@ -55,6 +57,8 @@ export function TransactionsMobileList({ onToggleSettlement, onAnticipate, onViewAnticipationHistory, + onConvertToInstallment, + onConvertToRecurring, isSettlementLoading, showActions = true, }: TransactionsMobileListProps) { @@ -74,6 +78,8 @@ export function TransactionsMobileList({ onToggleSettlement={onToggleSettlement} onAnticipate={onAnticipate} onViewAnticipationHistory={onViewAnticipationHistory} + onConvertToInstallment={onConvertToInstallment} + onConvertToRecurring={onConvertToRecurring} isSettlementLoading={isSettlementLoading} showActions={showActions} /> @@ -98,6 +104,8 @@ function TransactionMobileCard({ onToggleSettlement, onAnticipate, onViewAnticipationHistory, + onConvertToInstallment, + onConvertToRecurring, isSettlementLoading, showActions = true, }: TransactionMobileCardProps) { @@ -261,6 +269,8 @@ function TransactionMobileCard({ onRefund={onRefund} onAnticipate={onAnticipate} onViewAnticipationHistory={onViewAnticipationHistory} + onConvertToInstallment={onConvertToInstallment} + onConvertToRecurring={onConvertToRecurring} /> ) : null} diff --git a/src/features/transactions/components/table/transactions-table.tsx b/src/features/transactions/components/table/transactions-table.tsx index cd48b9c..4b78aa2 100644 --- a/src/features/transactions/components/table/transactions-table.tsx +++ b/src/features/transactions/components/table/transactions-table.tsx @@ -71,6 +71,8 @@ type TransactionsTableProps = { onBulkImport?: (items: TransactionItem[]) => void; onViewDetails?: (item: TransactionItem) => void; onRefund?: (item: TransactionItem) => void; + onConvertToInstallment?: (item: TransactionItem) => void; + onConvertToRecurring?: (item: TransactionItem) => void; onToggleSettlement?: (item: TransactionItem) => void; onAnticipate?: (item: TransactionItem) => void; onViewAnticipationHistory?: (item: TransactionItem) => void; @@ -100,6 +102,8 @@ export function TransactionsTable({ onBulkImport, onViewDetails, onRefund, + onConvertToInstallment, + onConvertToRecurring, onToggleSettlement, onAnticipate, onViewAnticipationHistory, @@ -134,6 +138,8 @@ export function TransactionsTable({ onConfirmDelete, onViewDetails, onRefund, + onConvertToInstallment, + onConvertToRecurring, onToggleSettlement, onAnticipate, onViewAnticipationHistory, @@ -151,6 +157,8 @@ export function TransactionsTable({ onConfirmDelete, onViewDetails, onRefund, + onConvertToInstallment, + onConvertToRecurring, onToggleSettlement, onAnticipate, onViewAnticipationHistory, diff --git a/src/features/transactions/lib/installment-detection.ts b/src/features/transactions/lib/installment-detection.ts new file mode 100644 index 0000000..861b500 --- /dev/null +++ b/src/features/transactions/lib/installment-detection.ts @@ -0,0 +1,49 @@ +export type InstallmentDetection = { + name: string; + currentInstallment: number; + installmentCount: number; +}; + +const INSTALLMENT_SUFFIX_PATTERNS = [ + /^(?.+?)\s*[-–—]?\s*parcela\s+(?\d{1,2})\s+de\s+(?\d{1,2})\s*$/iu, + /^(?.+?)\s*[-–—]?\s*parcela\s+(?\d{1,2})\s*\/\s*(?\d{1,2})\s*$/iu, + /^(?.+?)\s*\((?\d{1,2})\s*[/?]\s*(?\d{1,2})\)\s*$/u, + /^(?.+?)\s+(?\d{1,2})\s*[/?]\s*(?\d{1,2})\s*$/u, +]; + +const normalizeDetectedName = (value: string) => + value + .trim() + .replace(/\s+[-–—:]\s*$/u, "") + .trim(); + +export function detectInstallmentFromName( + value: string | null | undefined, +): InstallmentDetection | null { + const text = value?.trim(); + if (!text) return null; + + for (const pattern of INSTALLMENT_SUFFIX_PATTERNS) { + const match = pattern.exec(text); + const groups = match?.groups; + if (!groups) continue; + + const currentInstallment = Number(groups.current); + const installmentCount = Number(groups.total); + const name = normalizeDetectedName(groups.name ?? ""); + + if ( + name.length > 0 && + Number.isInteger(currentInstallment) && + Number.isInteger(installmentCount) && + currentInstallment >= 1 && + installmentCount >= 2 && + currentInstallment <= installmentCount && + installmentCount <= 60 + ) { + return { name, currentInstallment, installmentCount }; + } + } + + return null; +}