Field meal allowance audit
Fictional case. Fiorena Redes Elétricas S.A., its bases, people and records do not exist. The data were generated from scratch by an R script, with made-up allowance rules and defects of known size. The method is real.
Download the report (PDF) · Download the cases to review (CSV)
The comments in the R code are in Portuguese, the case’s original language.
The question
Fiorena’s financial control team saw the meal allowance paid per crew-day in the field rise from R$ 219 in the first half of 2025 to R$ 239 in the second, 9.5% more, with no change in the allowance amounts or in the type of work. The question was simple: how much of that is legitimate spending, and how much was paid outside the rules?
The July dip has a cause in the data: July and January are the months with the most people on vacation (in July, 81 lineworkers per business day, against 37 in the median month), and each crew went out with 3.5 people, against 3.8 in the median. The amount per person in the field did not fall: it stayed between R$ 58 and R$ 60 from January to July.
Each lineworker gets one top-up on their meal card per day in the field. The base enters the top-up, the supervisor approves it, and it is paid with no automatic check against the field log or the vacation and leave records. Nobody had looked at all 12 months of records together.
The audit answered three questions, in this order:
- How much was paid outside an objective rule (duplicates, over the cap, people who were not working)?
- How much was paid with no support: no record of the crew in the field that day, or a justification that explains nothing?
- Where is it concentrated, and why?
The rule and the data
The allowance rule:
- Lunch of R$ 52 per day in the field.
- Dinner of R$ 44, only when the field log records an overnight stay for the crew.
- Extra of up to R$ 24, only with one of the listed justifications: “travel of more than 300 km” or “workday extended by more than 2 hours”.
- Cap of R$ 120 per person per day, all included, and at most one top-up of each type per day.
- Only for people who are active, not on vacation or leave, and on a crew that was in the field that day.
The audit covered 155,909 top-ups from 2025, totaling R$ 7.63 million, paid to 649 people at 8 bases, cross-checked against 33,254 crew-in-the-field records in the log, the HR roster, and HR’s vacation and leave records.
Method
Every top-up goes through the same checks and ends up in one of four conclusions. The audit separates objective errors, which can be recovered, from what only deserves a second look from the base; it draws no conclusion about anyone’s intent.
- Prepare. Use the day worked, not the date the top-up was entered, and link each top-up to the HR roster, to leave records and to the person’s crew that day.
- Check the objective rules. Duplicates, person not on duty, dinner with no overnight stay, extra with no justification, and a daily total over the cap. For the cap, only the excess counts as improper.
- Check the support. Was the crew in the field that day? Is the justification for the extra valid, vague or missing? A term dictionary tolerates typos, so that “deslocamneto acima de 300km” (misspelled, in Portuguese) still counts as valid.
- Compare with what is expected. For each base and month, the amount per field day is compared with the other bases in the same month. A group falls outside the expected range when it strays much further than bases usually stray from one another (Tukey’s criterion, in the code).
- Score the risk. One point for each signal: rule broken, no field record, vague or missing justification, base and month above the range.
- Conclude. Each top-up gets one of the four conclusions below.
| Conclusion | When | What to do |
|---|---|---|
| Improper | Broke an objective rule | Reverse or recover, according to company policy and with support from HR and legal |
| Review | No rule broken, but no field record, or two signals or more | The base confirms or corrects it within 15 days |
| Watch | A single signal, no rule broken | Goes into next month’s sample |
| Compliant | No signal | Nothing to do |
Improper
When: Broke an objective rule
What to do: Reverse or recover, according to company policy and with support from HR and legal
Review
When: No rule broken, but no field record, or two signals or more
What to do: The base confirms or corrects it within 15 days
Watch
When: A single signal, no rule broken
What to do: Goes into next month's sample
Compliant
When: No signal
What to do: Nothing to do
Show the R code for step 1: prepare
# O que vale é o dia de referência (o dia trabalhado), e não a data em que a
# recarga foi lançada. Cada recarga é ligada ao cadastro, às ausências e à
# equipe da pessoa naquele dia no diário de campo.
recargas <- read_csv("dados/recargas.csv.gz", show_col_types = FALSE, na = "",
col_types = cols(justificativa = "c"))
diario <- read_csv("dados/diario_campo.csv", show_col_types = FALSE)
cadastro <- read_csv("dados/cadastro.csv", show_col_types = FALSE, na = "")
ausencias <- read_csv("dados/ausencias.csv", show_col_types = FALSE)
base_auditoria <- recargas |>
mutate(dia = dia_referencia) |>
left_join(cadastro |> select(matricula, equipe, admissao, desligamento), "matricula") |>
left_join(ausencias |> select(matricula, tipo_ausencia = tipo, inicio, fim),
join_by(matricula, between(dia, inicio, fim))) |>
left_join(diario |> transmute(equipe, dia = data, em_campo = TRUE, pernoite = pernoite == "sim"),
c("equipe", "dia")) |>
mutate(em_campo = coalesce(em_campo, FALSE))Show the R code for step 2: objective rules
# Regras objetivas: cada violação pode ser cobrada. No teto, só o excedente
# conta como indevido; ele é calculado depois de tirar o que já é indevido por
# outra regra, para não contar o mesmo real duas vezes.
normalizar <- function(x) {
x <- tolower(iconv(coalesce(x, ""), to = "ASCII//TRANSLIT"))
x <- gsub("([0-9])\\s*km", "\\1 km", x)
x <- gsub("([0-9])\\s*h\\b", "\\1 horas", x)
trimws(gsub("[^a-z0-9 ]", " ", gsub("\\s+", " ", x)))
}
# Dicionário com tolerância a erro de digitação: a justificativa vale se
# estiver a até 15% de distância (em letras) de uma das frases da regra.
classificar_justificativa <- function(x) {
n <- normalizar(x)
validas <- normalizar(JUSTIFICATIVAS_VALIDAS)
dist <- adist(n, validas, partial = FALSE) / outer(nchar(n), nchar(validas), pmax)
case_when(n == "" ~ "ausente",
apply(dist, 1, min) <= 0.15 ~ "válida",
TRUE ~ "vaga")
}
# Cada texto diferente é classificado uma vez só.
textos <- unique(coalesce(base_auditoria$justificativa[base_auditoria$tipo == "Extra"], ""))
classes <- classificar_justificativa(textos)
auditada <- base_auditoria |>
mutate(justificativa_classe = if_else(tipo == "Extra", classes[match(coalesce(justificativa, ""), textos)], NA)) |>
arrange(matricula, dia, tipo, data_recarga, id) |>
group_by(matricula, dia, tipo) |>
mutate(duplicidade = row_number() > 1) |>
ungroup() |>
mutate(
fora_do_trabalho = dia < admissao | (!is.na(desligamento) & dia > desligamento) | !is.na(tipo_ausencia),
jantar_sem_pernoite = tipo == "Jantar" & !coalesce(pernoite, FALSE),
extra_sem_justificativa = tipo == "Extra" & justificativa_classe == "ausente",
motivo_regra = case_when(duplicidade ~ "Duplicidade",
fora_do_trabalho ~ "Pessoa fora do trabalho",
jantar_sem_pernoite ~ "Jantar sem pernoite",
extra_sem_justificativa ~ "Extra sem justificativa"),
valor_indevido = if_else(is.na(motivo_regra), 0, valor)
)
# Teto diário: soma do que não é indevido por outra regra; o excedente sai
# primeiro do extra, depois do jantar.
ordem_corte <- c("Extra" = 1, "Jantar" = 2, "Almoço" = 3)
auditada <- auditada |>
mutate(restante = valor - valor_indevido, corte = ordem_corte[tipo]) |>
group_by(matricula, dia) |>
mutate(excedente_dia = pmax(sum(restante) - TETO, 0)) |>
arrange(corte, .by_group = TRUE) |>
mutate(antes = cumsum(restante) - restante,
acima_do_teto = pmin(restante, pmax(excedente_dia - antes, 0))) |>
ungroup() |>
mutate(valor_indevido = valor_indevido + acima_do_teto,
motivo_regra = if_else(is.na(motivo_regra) & acima_do_teto > 0, "Acima do teto", motivo_regra),
regra = !is.na(motivo_regra)) |>
select(-restante, -corte, -excedente_dia, -antes)Show the R code for step 3: support
# A equipe estava em campo naquele dia? A justificativa do extra é válida,
# vaga ou ausente?
auditada <- auditada |>
mutate(sem_lastro = !em_campo,
justificativa_fraca = tipo == "Extra" & justificativa_classe %in% c("vaga", "ausente"))Show the R code for step 4: expected range
# Valor pago por dia de equipe em campo, por base e mês. Como o nível geral
# muda de um mês para outro (mais pernoites no segundo semestre), cada base é
# comparada com a mediana das bases no mesmo mês; a faixa esperada desse
# índice vem do critério de Tukey sobre todos os grupos com pelo menos 15 dias
# de equipe em campo. Como a auditoria procura pagamento a mais, só ficar
# acima da faixa conta como sinal de risco; abaixo dela, o grupo só é anotado.
dias_campo <- diario |>
mutate(mes = format(data, "%Y-%m")) |>
count(base, mes, name = "dias_equipe")
base_mes <- auditada |>
mutate(mes = format(dia, "%Y-%m")) |>
group_by(base, mes) |>
summarise(valor = sum(valor), .groups = "drop") |>
left_join(dias_campo, c("base", "mes")) |>
mutate(valor_dia = valor / dias_equipe) |>
group_by(mes) |>
mutate(mediana_mes = median(valor_dia), indice = valor_dia / mediana_mes) |>
ungroup()
validos <- base_mes |> filter(dias_equipe >= 15)
q <- quantile(validos$indice, c(0.25, 0.75))
faixa <- c(q[[1]] - 1.5 * diff(q), q[[2]] + 1.5 * diff(q))
base_mes <- base_mes |>
mutate(faixa_min = faixa[1] * mediana_mes, faixa_max = faixa[2] * mediana_mes,
abaixo_da_faixa = dias_equipe >= 15 & indice < faixa[1],
fora_da_faixa = dias_equipe >= 15 & indice > faixa[2])
auditada <- auditada |>
mutate(mes = format(dia, "%Y-%m")) |>
left_join(base_mes |> select(base, mes, fora_da_faixa), c("base", "mes")) |>
mutate(fora_da_faixa = coalesce(fora_da_faixa, FALSE))Show the R code for step 5: risk score
# Um ponto para cada sinal, de 0 a 4.
auditada <- auditada |>
mutate(score = regra + sem_lastro + justificativa_fraca + fora_da_faixa,
score_sem_regra = score - regra)Show the R code for step 6: conclusion
# Indevido: violou uma regra objetiva. Revisar: sem regra violada, mas sem
# lastro de campo ou com score de 2 ou mais. Atenção: score 1. Regular: 0.
# Numa recarga que passou do teto só em parte, o excedente é Indevido e o
# resto recebe a conclusão que teria sem essa regra.
concluir <- function(sem_lastro, score) {
case_when(sem_lastro | score >= 2 ~ "Revisar", score == 1 ~ "Atenção", TRUE ~ "Regular")
}
auditada <- auditada |>
mutate(conclusao = if_else(regra, "Indevido", concluir(sem_lastro, score)),
conclusao_resto = concluir(sem_lastro, score_sem_regra),
valor_resto = valor - valor_indevido)
NIVEIS <- c("Indevido", "Revisar", "Atenção", "Regular")Results
Of the amount paid in the year, R$ 305 thousand (4.0%) was improper, and another R$ 235 thousand (3.1%) needs confirmation from the base.
- Improper is what broke an objective rule and can be recovered.
- Review is made up almost entirely of top-ups with no field record: R$ 234 thousand in 4,500 top-ups entered on days when the crew does not appear in the log. It may be a gap in the log rather than in the top-up; that is why the base confirms before anything is recovered.
- Watch is not an accusation. Of the 8,686 top-ups in Watch, 7,925 are there only because their base and month were above the expected range; the top-up itself follows the rule. Among them are 1,072 records that only looked like errors (entered one or two days late, overnight stay logged late, justification with a typo). The other 872 have a vague justification, such as “ajuste” (adjustment) or “conforme combinado” (as agreed).
Improper payments by type of defect
What explains the jump in the second half
The amount per field day rose by R$ 20.86. Of that increase, R$ 10.21 (49%) came from improper payments. Another R$ 8.51 (41%) came from more legitimate dinners, because overnight stays went from 11.5% to 16.8% of field days. The remaining R$ 2.14 came from the extras that come with overnight stays.
Where and why
- The August system switch created duplicates. Through July they add up to R$ 6 thousand; from August to December, R$ 112 thousand. The new system accepts two top-ups of the same type for the same person on the same day.
- The HR roster arrives late. Of the top-ups paid to people on vacation, 100% fell in the first five days of the vacation period: the base only learns about the vacation after it has started.
- Two bases pay dinner with no overnight stay. Base 3 and Base 6 account for 100% of dinners with no overnight stay, and they are the only bases with months above the expected range: 3 months at the first and 3 at the second.
Cases to review
The full list has 12,033 top-ups classified as Improper or Review, sorted by conclusion, risk and amount, and can be downloaded at the top of the page. Below, first the 5 people with the largest improper amount (only the improper part of each top-up), then the 5 with the largest amount to review.
| Conclusion | Employee ID | Base | Period | Top-ups | Amount | Reason |
|---|---|---|---|---|---|---|
| Improper | F6561 | Base 3 | Jan 6 to Dec 17, 2025 | 40 | R$ 1,692 | Dinner, no overnight stay |
| Improper | F5109 | Base 3 | Feb 24 to Dec 11, 2025 | 40 | R$ 1,684 | Dinner, no overnight stay |
| Improper | F5730 | Base 6 | Mar 27 to Dec 31, 2025 | 37 | R$ 1,616 | Dinner, no overnight stay |
| Improper | F4119 | Base 6 | Apr 22 to Dec 26, 2025 | 37 | R$ 1,580 | Dinner, no overnight stay |
| Improper | F5573 | Base 3 | Jan 23 to Dec 29, 2025 | 37 | R$ 1,568 | Dinner, no overnight stay |
| Review | F3012 | Base 2 | Jan 13 to Dec 29, 2025 | 18 | R$ 936 | No field record |
| Review | F8211 | Base 6 | Jan 23 to Dec 31, 2025 | 18 | R$ 936 | No field record |
| Review | F1355 | Base 6 | Jan 8 to Dec 23, 2025 | 18 | R$ 872 | No field record |
| Review | F6862 | Base 6 | Jan 15 to Dec 16, 2025 | 17 | R$ 852 | No field record |
| Review | F1035 | Base 2 | Jan 27 to Dec 29, 2025 | 16 | R$ 832 | No field record |
Improper
Employee ID: F6561
Base: Base 3
Period: Jan 6 to Dec 17, 2025
Top-ups: 40
Amount: R$ 1,692
Reason: Dinner, no overnight stay
Improper
Employee ID: F5109
Base: Base 3
Period: Feb 24 to Dec 11, 2025
Top-ups: 40
Amount: R$ 1,684
Reason: Dinner, no overnight stay
Improper
Employee ID: F5730
Base: Base 6
Period: Mar 27 to Dec 31, 2025
Top-ups: 37
Amount: R$ 1,616
Reason: Dinner, no overnight stay
Improper
Employee ID: F4119
Base: Base 6
Period: Apr 22 to Dec 26, 2025
Top-ups: 37
Amount: R$ 1,580
Reason: Dinner, no overnight stay
Improper
Employee ID: F5573
Base: Base 3
Period: Jan 23 to Dec 29, 2025
Top-ups: 37
Amount: R$ 1,568
Reason: Dinner, no overnight stay
Review
Employee ID: F3012
Base: Base 2
Period: Jan 13 to Dec 29, 2025
Top-ups: 18
Amount: R$ 936
Reason: No field record
Review
Employee ID: F8211
Base: Base 6
Period: Jan 23 to Dec 31, 2025
Top-ups: 18
Amount: R$ 936
Reason: No field record
Review
Employee ID: F1355
Base: Base 6
Period: Jan 8 to Dec 23, 2025
Top-ups: 18
Amount: R$ 872
Reason: No field record
Review
Employee ID: F6862
Base: Base 6
Period: Jan 15 to Dec 16, 2025
Top-ups: 17
Amount: R$ 852
Reason: No field record
Review
Employee ID: F1035
Base: Base 2
Period: Jan 27 to Dec 29, 2025
Top-ups: 16
Amount: R$ 832
Reason: No field record
How we know the audit got it right
Because the data for this case were generated by a script, the audit could be checked against what the generator planted: it found all 12,905 planted defects, and none of the 19,885 records that only looked like errors ended up as Improper or Review. The audit does not read the answer key; a separate script compares the two at the end. Real data have no answer key, and that check is done with the base: a sample of cases from each conclusion is reviewed with people who know the process before the report is closed.
Recommendations
Each finding becomes a fix in the process, so the problem does not come back the following year.
| Finding | Recommended fix | Owner |
|---|---|---|
| Duplicates after the system switch | System block allowing one top-up per type, person and day | IT and the card provider |
| Late HR roster | Daily list of vacations, leaves and terminations sent to the top-up system | HR |
| Dinner with no overnight stay | Dinner released only when the field log records an overnight stay | Operations and base supervisors |
| Over the cap | Daily cap enforced by the system itself | IT |
| Vague justification | Justification picked from a closed list, not typed in | Financial control |
| Amounts already paid as improper | Reversal or recovery according to company policy | HR, with support from legal |
Duplicates after the system switch
Recommended fix: System block allowing one top-up per type, person and day
Owner: IT and the card provider
Late HR roster
Recommended fix: Daily list of vacations, leaves and terminations sent to the top-up system
Owner: HR
Dinner with no overnight stay
Recommended fix: Dinner released only when the field log records an overnight stay
Owner: Operations and base supervisors
Over the cap
Recommended fix: Daily cap enforced by the system itself
Owner: IT
Vague justification
Recommended fix: Justification picked from a closed list, not typed in
Owner: Financial control
Amounts already paid as improper
Recommended fix: Reversal or recovery according to company policy
Owner: HR, with support from legal
The audit also leaves three KPIs to track every month:
- Improper top-up rate: improper amount ÷ amount paid, with a target below 0.5%.
- Field record coverage: top-ups with the crew recorded in the field ÷ total top-ups, with a target above 99%.
- Amount per field day: by base, against the expected range.
What this case demonstrates
- Cross-checking sources nobody cross-checked. The error was not in any single source, but in the lack of checks between the card, the field log and HR.
- Separating objective errors from suspicion. Only what breaks a rule goes to recovery; the rest goes back to the base to confirm, without accusing anyone.
- A number that explains the problem. The jump in the second half was split into what is improper and what is legitimate, instead of turning into a blanket order to cut costs.
- A report that can be rerun. The code stays with the company, and the same report runs the following year with new data.
Does your company pay a benefit, reimbursement or allowance without cross-checking it against another source?
Tell me how it works today and I will tell you what can be checked.







