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 == 0 tem 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,03 parece inofensivo até aparecer num relatório para o cliente.
  • break quando 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

  1. 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). Com i = 0, o denominador (1+0)^n − 1 vale zero, e a operação produz NaN — 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ícito parcela <- valor / meses, que é matematicamente o limite da fórmula quando i → 0.

  2. 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,0000003 ou 0,0000004, que vira -0,00 ou 0,00 na 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 verificar abs(saldo_final) < 1e-6 em vez de saldo_final == 0 — e testar também valores quebrados.

  3. 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.

  4. Por que o cálculo deve ficar numa função pura em vez de dentro de um reactive?

    ✓ Resposta:

    Porque dentro de um reactive o 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, sai data.frame — ele é testável com testthat em milissegundos, reutilizável em qualquer contexto e depurável isoladamente. O reactive fica apenas com o papel que lhe cabe: ler os inputs, chamar a função e devolver o resultado.

  5. 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. Com validate(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.

Fontes e leituras recomendadas