Field meal allowance audit

Audit
R
Quarto
Benefits
A year of meal-card top-ups cross-checked against the field log and the HR roster. How much was paid outside the rules, where and why.

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?

Two monthly lines. The amount paid rises from about 205 reais in January to nearly 250 reais in December, with a jump in August. The line without improper payments rises less and stays below the other from August on.

Meal allowance paid per crew-day in the field, month by month, in 2025. The dashed line is the same amount without what the audit classified as improper. The mark in August is the switch to a new top-up system.

Two monthly lines. The amount paid rises from about 205 reais in January to nearly 250 reais in December, with a jump in August. The line without improper payments rises less and stays below the other from August on.

Meal allowance paid per crew-day in the field, month by month, in 2025. The dashed line is the same amount without what the audit classified as improper. The mark in August is the switch to a new top-up system.

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:

  1. How much was paid outside an objective rule (duplicates, over the cap, people who were not working)?
  2. How much was paid with no support: no record of the crew in the field that day, or a justification that explains nothing?
  3. 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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).
  5. Score the risk. One point for each signal: rule broken, no field record, vague or missing justification, base and month above the range.
  6. 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.

A horizontal bar with the year's total. Improper, Review and Watch together take up a little more than a tenth of the bar; the rest is Compliant.

The amount paid in 2025, split into the audit’s four conclusions.

A horizontal bar with the year's total. Improper, Review and Watch together take up a little more than a tenth of the bar; the rest is Compliant.

The amount paid in 2025, split into the audit’s four conclusions.
  • 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

Horizontal bars from largest to smallest. Duplicates, in blue, is the largest defect, followed by dinner with no overnight stay, person not on duty, over the cap and extra with no justification.

Improper amount in 2025 by type of defect. For the cap, only the excess over the daily limit.

Horizontal bars from largest to smallest. Duplicates, in blue, is the largest defect, followed by dinner with no overnight stay, person not on duty, over the cap and extra with no justification.

Improper amount in 2025 by type of defect. For the cap, only the excess over the daily limit.

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.

Eight small charts, one per base. At six bases the line stays inside the gray range all year. At bases 3 and 6, in blue, the line goes above the range in three months each.

Amount paid per crew-day in the field, by base and month, against the expected range (gray area). Blue points are the months above the range.

Eight small charts, one per base. At six bases the line stays inside the gray range all year. At bases 3 and 6, in blue, the line goes above the range in three months each.

Amount paid per crew-day in the field, by base and month, against the expected range (gray area). Blue points are the months above the range.

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.

Get in touch