← Python for Treasury Work

Lesson 3 · Beginner · 20 min

Compute real property tax penalties with Decimal

See why floats are unsafe for money, then compute 2% monthly penalties with a 36-month cap, rounded correctly.

✓ Last checked: November 2026 · code run, output real, facts checked

Decimalfunctionsroundingmin()

In this lesson

  • See the rounding errors that floats introduce
  • Write a small, reusable function
  • Round to the centavo with ROUND_HALF_UP
  • Apply a ceiling with min()

RA 7160 charges interest of 2% a month on delinquent real property tax, for at most 36 months; most offices simply call it the penalty. The computation looks simple until the centavos stop matching. Before writing the penalty function, it is worth seeing why money in Python should be handled with the Decimal type and not with ordinary float numbers.

Why not float?

why_decimal.pyDownload
"""Why money should not be stored as float."""

from decimal import Decimal

print(0.10 + 0.20)                         # float
print(Decimal("0.10") + Decimal("0.20"))   # Decimal

# Add one centavo a hundred thousand times
total_float = 0.0
total_decimal = Decimal("0.00")
for _ in range(100_000):
    total_float += 0.01
    total_decimal += Decimal("0.01")

print(total_float)
print(total_decimal)
Output of why_decimal.py
0.30000000000000004
0.30
999.9999999992356
1000.00

A float stores numbers in binary, and most decimal fractions like 0.10 cannot be stored exactly. The error is tiny at first, but it builds up over many additions. Decimal keeps the amount exactly as written, which is what a treasury report needs.

The data

Save this as delinquent.csv. The months_late column is the number of months of delinquency, already counted for each tax declaration.

delinquent.csvDownload
td_number,owner,year,tax_due,months_late
TD-2021-00145,Juan Dela Cruz,2024,1250.75,34
TD-2019-00872,Rosa Mendoza,2022,980.40,58
TD-2024-01130,Santos Bakery,2026,4580.33,10

The code

rpt_penalty.pyDownload
"""Compute the penalty on delinquent real property tax with exact decimals.

The rule used here: 2% of the unpaid tax for every month of delinquency,
counted up to a ceiling of 36 months (72%), as provided in Section 255 of RA 7160.
"""

import csv
from decimal import Decimal, ROUND_HALF_UP

RATE_PER_MONTH = Decimal("0.02")
MAX_MONTHS = 36
CENTAVO = Decimal("0.01")


def penalty(tax_due: Decimal, months_late: int) -> Decimal:
    months = min(months_late, MAX_MONTHS)
    amount = tax_due * RATE_PER_MONTH * months
    return amount.quantize(CENTAVO, rounding=ROUND_HALF_UP)


print(f"{'TD number':<15}{'Year':>5}{'Tax due':>11}{'Months':>8}{'Penalty':>11}{'Total':>11}")
with open("delinquent.csv", newline="", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        tax = Decimal(row["tax_due"])
        months = int(row["months_late"])
        pen = penalty(tax, months)
        shown = f"{min(months, MAX_MONTHS)}" + ("*" if months > MAX_MONTHS else "")
        print(f"{row['td_number']:<15}{row['year']:>5}{tax:>11,.2f}{shown:>8}{pen:>11,.2f}{tax + pen:>11,.2f}")

print("* capped at 36 months")

Output

Output of rpt_penalty.py
TD number       Year    Tax due  Months    Penalty      Total
TD-2021-00145   2024   1,250.75      34     850.51   2,101.26
TD-2019-00872   2022     980.40     36*     705.89   1,686.29
TD-2024-01130   2026   4,580.33      10     916.07   5,496.40
* capped at 36 months

How it works

def penalty(tax_due, months_late) puts the rule in one place. If the rule changes, you change one function and every report that uses it follows.

min(months_late, MAX_MONTHS) applies the ceiling. A property delinquent for 58 months is charged for 36, the maximum.

quantize(CENTAVO, rounding=ROUND_HALF_UP) rounds to two decimal places the way people round by hand: 916.066 becomes 916.07, and a half centavo always goes up.

Note: This lesson keeps the month count as given. How months are counted (from which date, and how partial months and installments are treated) follows your local revenue code and the applicable rules, so check those before using a script like this for real assessments.

Try it yourself

Practice

Add a discount function for early payment: a percentage off the tax due when the tax is paid in advance. Keep it in Decimal and round it the same way.

Show answer
answer_discount.pyDownload
"""Answer: a discount for paying real property tax in advance.

The rate here (10%) is only an example. RA 7160 lets the sanggunian set a
discount of up to 20%, so use the rate in your own ordinance.
"""

from decimal import Decimal, ROUND_HALF_UP

CENTAVO = Decimal("0.01")


def discount(tax_due: Decimal, rate_percent: Decimal) -> Decimal:
    amount = tax_due * rate_percent / 100
    return amount.quantize(CENTAVO, rounding=ROUND_HALF_UP)


for tax in (Decimal("1250.75"), Decimal("980.40"), Decimal("4580.33")):
    off = discount(tax, Decimal("10"))
    print(f"Tax {tax:>9,.2f}   discount {off:>7,.2f}   to pay {tax - off:>9,.2f}")
Output of answer_discount.py
Tax  1,250.75   discount  125.08   to pay  1,125.67
Tax    980.40   discount   98.04   to pay    882.36
Tax  4,580.33   discount  458.03   to pay  4,122.30