Até aqui as aulas isolaram técnicas: gráficos que conversam, mapas, tabelas editáveis. Esta aula faz o contrário — pega um problema de negócio completo e o atravessa do cálculo à interface, porque é na costura que os problemas reais aparecem.
O problema escolhido é um simulador de financiamento. Ele é um bom exemplo por três razões: a matemática é exata e verificável (não há "mais ou menos certo" numa tabela de amortização), o resultado é naturalmente uma tabela longa e um conjunto de indicadores e um gráfico — os três formatos que você já sabe construir —, e a regra de negócio tem sutilezas que forçam decisões de arquitetura.
Este mesmo simulador volta nas aulas de módulos, Golem e Docker. Vale construí-lo direito agora.
Construir um simulador de financiamento completo: implementar os sistemas de amortização SAC e Price como funções puras e testáveis, tratar amortizações extraordinárias, e montar a interface com KPIs, tabela e gráfico — separando com clareza o que é cálculo do que é apresentação.
A matemática, antes de qualquer linha de Shiny
Um financiamento é uma dívida que se paga em prestações, e cada prestação tem duas partes: juros (sobre o saldo devedor) e amortização (o que efetivamente abate a dívida). Os dois sistemas mais usados no Brasil diferem em qual das duas partes é mantida constante.
SAC (Sistema de Amortização Constante). A amortização é fixa: divide-se o valor financiado pelo número de parcelas. Como o saldo devedor cai de forma constante, os juros caem a cada mês, e a prestação decresce.
$$A = \frac{VF}{n} \qquad J_t = S_{t-1} \cdot i \qquad P_t = A + J_t$$
Price (prestações constantes). A prestação é fixa, calculada de modo que o valor presente das parcelas iguale o financiado. Nos primeiros meses quase tudo é juro; a amortização cresce ao longo do tempo.
$$P = VF \cdot \frac{i(1+i)^n}{(1+i)^n - 1}$$
A consequência prática, e é ela que o cliente quer entender: o SAC custa menos juros no total, porque abate a dívida mais rápido; em compensação, as primeiras prestações são mais altas. O Price cabe melhor no orçamento no começo e custa mais no fim.
O cálculo como função pura
A tentação é calcular dentro de um reactive. Resista. O cálculo vai numa função que não sabe que o Shiny existe:
#' Gera a tabela de amortização de um financiamento.
#'
#' @param valor valor financiado (após a entrada)
#' @param meses prazo em meses
#' @param taxa_mensal taxa de juros ao mês, em fração (0.008 = 0,8%)
#' @param sistema "SAC" ou "PRICE"
#' @param extras data.frame com colunas `mes` e `valor`, ou NULL
simular_financiamento <- function(valor, meses, taxa_mensal,
sistema = c("SAC", "PRICE"), extras = NULL) {
sistema <- match.arg(sistema)
stopifnot(valor > 0, meses >= 1, taxa_mensal >= 0)
parcela_price <- if (taxa_mensal == 0) valor / meses else
valor * (taxa_mensal * (1 + taxa_mensal)^meses) / ((1 + taxa_mensal)^meses - 1)
amortizacao_sac <- valor / meses
saldo <- valor
linhas <- vector("list", meses)
for (m in seq_len(meses)) {
if (saldo <= 1e-8) break
juros <- saldo * taxa_mensal
amort <- if (sistema == "SAC") amortizacao_sac else parcela_price - juros
amort <- min(amort, saldo) # a última parcela não pode passar do saldo
extra <- if (is.null(extras)) 0 else sum(extras$valor[extras$mes == m])
extra <- min(extra, saldo - amort) # nem o extra
saldo <- saldo - amort - extra
linhas[[m]] <- data.frame(
mes = m, juros = juros, amortizacao = amort,
extra = extra, parcela = amort + juros, saldo = saldo
)
}
do.call(rbind, linhas[!vapply(linhas, is.null, logical(1))])
}
Três decisões escondidas nesse código, e vale explicitá-las:
taxa_mensal == 0tem tratamento próprio. A fórmula do Price divide por(1+i)^n - 1, que é zero quando a taxa é zero. Sem o caso especial, o app quebra num cenário perfeitamente legítimo (simular sem juros para comparação).min(amort, saldo)evita saldo negativo na última parcela por acúmulo de arredondamento. Um saldo final de-0,03parece inofensivo até aparecer num relatório para o cliente.breakquando o saldo zera. Com amortizações extras, a dívida pode acabar antes do prazo — e o simulador precisa dizer isso, não gerar parcelas fantasmas de valor zero.
Sendo pura, essa função é testável sem levantar o app:
test_that("SAC tem amortização constante e quita no prazo", {
tb <- simular_financiamento(120000, 120, 0.008, "SAC")
expect_equal(nrow(tb), 120)
expect_equal(length(unique(round(tb$amortizacao, 6))), 1)
expect_lt(abs(tb$saldo[120]), 1e-6)
})
test_that("Price tem parcela constante", {
tb <- simular_financiamento(120000, 120, 0.008, "PRICE")
expect_equal(length(unique(round(tb$parcela, 6))), 1)
})
test_that("SAC custa menos juros que Price no mesmo cenário", {
sac <- simular_financiamento(120000, 120, 0.008, "SAC")
price <- simular_financiamento(120000, 120, 0.008, "PRICE")
expect_lt(sum(sac$juros), sum(price$juros))
})
O terceiro teste é o mais valioso: ele codifica uma regra de negócio que precisa continuar verdadeira depois de qualquer refatoração.
Amortização extraordinária: onde mora a regra de negócio
Quando o cliente adianta um valor, existe uma escolha que muda o resultado e que o app precisa deixar explícita: abater o prazo ou abater a prestação.
- Reduzir prazo — mantém a prestação e quita antes. É a opção que economiza mais juros.
- Reduzir prestação — mantém o prazo e alivia o mês a mês.
No SAC, "reduzir prazo" sai naturalmente do código acima: a amortização mensal continua fixa e o saldo acaba antes. Para "reduzir prestação", recalcula-se a amortização com o prazo remanescente:
if (estrategia == "reduzir_prestacao" && extra > 0) {
meses_restantes <- meses - m
if (meses_restantes > 0) {
amortizacao_sac <- saldo / meses_restantes
parcela_price <- if (taxa_mensal == 0) saldo / meses_restantes else
saldo * (taxa_mensal * (1 + taxa_mensal)^meses_restantes) /
((1 + taxa_mensal)^meses_restantes - 1)
}
}
Interface honesta mostra as duas simulações lado a lado, com a diferença de juros totais em destaque. A decisão é do cliente, mas o número que a sustenta é responsabilidade sua.
A interface: KPIs, tabela e gráfico
Com o cálculo resolvido, a UI fica quase trivial — que é justamente o sinal de que a separação foi bem feita:
library(shiny)
library(bslib)
ui <- page_sidebar(
title = "Simulador de Financiamento",
sidebar = sidebar(
width = 330,
numericInput("valor_bem", "Valor do bem (R$)", 300000, min = 1000, step = 1000),
numericInput("entrada", "Entrada (R$)", 60000, min = 0, step = 1000),
sliderInput("prazo", "Prazo (meses)", 12, 420, 240, step = 12),
numericInput("taxa", "Juros (% ao mês)", 0.85, min = 0, max = 5, step = 0.05),
radioButtons("sistema", "Sistema", c("SAC" = "SAC", "Price" = "PRICE")),
actionButton("simular", "Simular", class = "btn-primary w-100")
),
layout_columns(
fill = FALSE,
value_box("Primeira parcela", textOutput("kpi_primeira"), showcase = bsicons::bs_icon("arrow-up-right")),
value_box("Última parcela", textOutput("kpi_ultima"), showcase = bsicons::bs_icon("arrow-down-right")),
value_box("Total de juros", textOutput("kpi_juros"), showcase = bsicons::bs_icon("percent")),
value_box("Total pago", textOutput("kpi_total"), showcase = bsicons::bs_icon("cash-stack"))
),
navset_card_tab(
nav_panel("Evolução", plotly::plotlyOutput("grafico", height = 380)),
nav_panel("Parcelas", DT::DTOutput("tabela"))
)
)
server <- function(input, output, session) {
# eventReactive: o cálculo só roda no clique, não a cada dígito digitado
simulacao <- eventReactive(input$simular, {
financiado <- input$valor_bem - input$entrada
validate(
need(financiado > 0, "A entrada não pode ser maior ou igual ao valor do bem."),
need(input$taxa >= 0, "A taxa não pode ser negativa.")
)
simular_financiamento(financiado, input$prazo, input$taxa / 100, input$sistema)
}, ignoreNULL = FALSE)
moeda <- function(x) paste0("R$ ", formatC(x, format = "f", digits = 2,
big.mark = ".", decimal.mark = ","))
output$kpi_primeira <- renderText(moeda(simulacao()$parcela[1]))
output$kpi_ultima <- renderText(moeda(tail(simulacao()$parcela, 1)))
output$kpi_juros <- renderText(moeda(sum(simulacao()$juros)))
output$kpi_total <- renderText(moeda(sum(simulacao()$parcela) + sum(simulacao()$extra)))
}
O eventReactive amarrado ao botão não é preciosismo: sem ele, arrastar o slider de prazo dispara uma simulação de 420 linhas a cada pixel de movimento.
E o validate(need(...)) trata o caso que o usuário vai fazer — digitar uma entrada maior que o valor do bem — com uma mensagem em vez de um erro vermelho de R.
A composição da parcela, em um gráfico
O gráfico que mais comunica aqui é a área empilhada de juros e amortização: ele mostra, de relance, a diferença entre os dois sistemas.
output$grafico <- plotly::renderPlotly({
tb <- simulacao()
plotly::plot_ly(tb, x = ~mes) |>
plotly::add_trace(y = ~amortizacao, name = "Amortização", type = "scatter",
mode = "none", stackgroup = "um", fillcolor = "#2b6cb0") |>
plotly::add_trace(y = ~juros, name = "Juros", type = "scatter",
mode = "none", stackgroup = "um", fillcolor = "#e53e3e") |>
plotly::layout(
yaxis = list(title = "R$ por parcela"),
xaxis = list(title = "Mês"),
hovermode = "x unified"
)
})
No Price, o usuário vê a faixa vermelha larga no começo e estreita no fim, com o topo reto — a prestação constante. No SAC, vê o topo descendo. Nenhum texto explica isso tão rápido.
Dinheiro merece cuidado extra
Duas armadilhas que aparecem em qualquer app financeiro:
Ponto flutuante. 0.1 + 0.2 != 0.3 em qualquer linguagem com double, R incluído. Para exibição, arredonde só na formatação. Para comparações, nunca use == com valores monetários — use uma tolerância (abs(a - b) < 0.005). Para sistemas onde o centavo é contratual, o correto é trabalhar em centavos inteiros.
Formatação brasileira. format() com as opções padrão produz 300,000.00, que um usuário brasileiro lê como trezentos mil vírgula zero. Sempre big.mark = "." e decimal.mark = ",". Parece detalhe; é a primeira coisa que denuncia um app mal-acabado.
Os dois sistemas se distinguem pelo que mantêm constante: o SAC mantém a amortização, com prestação decrescente e menos juros no total; o Price mantém a prestação, cobrando mais juros em troca de previsibilidade. O cálculo vive numa função pura, fora do Shiny — testável, reutilizável e verificável com testthat —, e o teste mais valioso é o que codifica a regra de negócio, do tipo "SAC custa menos juros". Três casos-limite derrubam o app se ignorados: taxa zero, que divide por zero no Price; última parcela maior que o saldo, que produz saldo negativo; e quitação antecipada por amortização extra, que gera parcelas fantasma. A amortização extraordinária tem duas estratégias — reduzir prazo, que economiza mais juros, ou reduzir prestação —, e a interface honesta mostra as duas e a diferença entre elas. Do lado reativo, eventReactive no botão evita recalcular a cada pixel do slider, e validate(need(...)) converte erro de R em mensagem para o usuário. Uma área empilhada de juros e amortização comunica a diferença entre os sistemas melhor que qualquer parágrafo. E dinheiro pede disciplina própria: nunca compare com ==, arredonde só na exibição, e formate com big.mark = "." e decimal.mark = ",".
Exercícios
-
Por que a fórmula do Price precisa de um caso especial quando a taxa é zero, e que cenário legítimo do usuário cai nele?
✓ Resposta:A parcela do Price é
VF · i(1+i)^n / ((1+i)^n − 1). Comi = 0, o denominador(1+0)^n − 1vale zero, e a operação produzNaN— que se propaga por toda a tabela e pelos KPIs. O cenário legítimo é comum: o usuário zera a taxa para ver quanto pagaria sem juros, como base de comparação, ou simula um financiamento subsidiado a juro zero. O tratamento correto é o caso explícitoparcela <- valor / meses, que é matematicamente o limite da fórmula quandoi → 0. -
O que
amort <- min(amort, saldo)evita, e por que o problema não aparece nos testes com números redondos?✓ Resposta:Evita saldo devedor negativo na última parcela. Como cada iteração acumula erro de ponto flutuante, o saldo ao chegar na parcela final raramente é exatamente igual à amortização calculada — pode faltar ou sobrar frações de centavo. Sem a trava, o saldo final sai como algo do tipo
-0,0000003ou0,0000004, que vira-0,00ou0,00na formatação mas contamina somatórios e comparações. Com números redondos e taxas "boas" o erro pode cair abaixo da visibilidade, por isso o teste deve verificarabs(saldo_final) < 1e-6em vez desaldo_final == 0— e testar também valores quebrados. -
Um cliente vai adiantar R$ 20.000. Explique as duas estratégias possíveis e qual economiza mais juros.
✓ Resposta:Reduzir prazo: a prestação continua a mesma e o financiamento termina antes. Reduzir prestação: o prazo continua o mesmo e o valor mensal cai. Economiza mais juros a redução de prazo, porque os juros incidem sobre o saldo devedor ao longo do tempo — cortar meses de incidência vale mais que diluir o mesmo saldo pelo prazo inteiro. A redução de prestação é preferível quando o objetivo é aliviar o orçamento mensal imediato, e essa é uma decisão do cliente. O papel do app é mostrar as duas simulações lado a lado com a diferença de juros totais explícita, para que a escolha seja informada.
-
Por que o cálculo deve ficar numa função pura em vez de dentro de um
reactive?✓ Resposta:Porque dentro de um
reactiveo cálculo fica acoplado ao Shiny: não dá para testá-lo sem levantar a aplicação e simular sessão, não dá para reaproveitá-lo num relatório ou numa API, e qualquer erro se confunde com erro de reatividade. Como função pura — entra número, saidata.frame— ele é testável comtestthatem milissegundos, reutilizável em qualquer contexto e depurável isoladamente. Oreactivefica apenas com o papel que lhe cabe: ler os inputs, chamar a função e devolver o resultado. -
Um usuário digita entrada de R$ 400.000 para um bem de R$ 300.000. O que o app deve fazer, e por que
validate(need(...))é melhor que deixar o erro acontecer?✓ Resposta:O valor financiado sairia negativo, e o
stopifnot(valor > 0)da função pura dispararia um erro. Sem tratamento, o Shiny exibe uma mensagem de erro técnica em vermelho — ou, pior, a tela cinza de "Disconnected" —, o que passa a impressão de aplicação quebrada por um erro que é do preenchimento, não do sistema. Comvalidate(need(financiado > 0, "A entrada não pode ser maior ou igual ao valor do bem.")), o Shiny substitui as saídas por essa frase, em linguagem de usuário, e o app continua utilizável: basta corrigir o campo. A validação da função pura permanece como a última linha de defesa.