I want to be very clear: this article is examining whether or not math is essential background knowledge for programmers. It is not arguing that math is not important or valuable.
I believe that most people can benefit from learning some math! You are absolutely affected by math in your day-to-day life, whether you understand it or not.
For example, many adults have a credit card. Credit cards typically come with an Annual Percentage Rate (APR), which is pretty confusing since most cards calculate daily compound interest. Compound interest could be considered the eighth wonder of the modern world and, when you compound it daily, it's simply terrifying. It's vital to understand at least the magnitude of how this grows if you carry a significant balance.
However, if you don't know this, programming might be able to save you! Let's build a model for credit cards:
defmodule CreditCard do
defstruct balance: 7_279.0,
apr: 24.45,
daily_periodic_rate: nil,
minimum_percent: 0.02,
minimum_floor: 27.5,
last_due: nil
def new(fields \\ []) do
fields
|> Keyword.put_new_lazy(:last_due, &Date.utc_today/0)
|> then(&struct!(__MODULE__, &1))
|> then(fn card ->
%__MODULE__{card | daily_periodic_rate: card.apr / 100 / 365}
end)
end
def payment_schedule(card, overpayment \\ 0) do
Stream.unfold(card, fn
%__MODULE__{balance: 0.0} ->
nil
card ->
payment =
CreditCard.Payment.new(card)
|> CreditCard.Payment.advance_due_date()
|> CreditCard.Payment.accrue_interest()
|> CreditCard.Payment.apply_payment(overpayment)
{payment, payment.card_after}
end)
end
end
This code above is mostly just a data structure. The defaults you see came from Google:
The CreditCard.payment_schedule/2
function provides a public interface for repeatedly making payments, until the balance reaches zero. A payment involves a few steps, but the keys bits are that interest is accrued for days past and then a payment is applied to the new total. A minimum payment is always made, but we can choose to pass an overpayment
amount.
The trickiest line in the code above is probably where I convert the APR to a daily_periodic_rate
. Is this using math knowledge? I don't think it has to be, since the formulas needed are easily Googled. (Yes, I've made some simplifying assumptions: assume the full balance is already subject to interest, ignore details like cash advances to assume the whole balance is under the same APR, and assume no new debt will be added.)
Let's look at the payments:
defmodule CreditCard.Payment do
defstruct ~w[card_before card_after days interest minimum paid]a
def new(card), do: %__MODULE__{card_before: card, card_after: card}
def advance_due_date(payment) do
# try to jump a month forward
last_due = payment.card_before.last_due
next_due = Date.add(last_due, Date.days_in_month(last_due))
# correct if we overshot a shorter month (not possible in November or
# December, since both December and January have 31 days)
next_due =
if next_due.month == last_due.month + 2 do
Date.add(next_due, -next_due.day)
else
next_due
end
days = Date.diff(next_due, last_due)
%__MODULE__{
payment
| card_after: %CreditCard{payment.card_after | last_due: next_due},
days: days
}
end
def accrue_interest(payment) do
card = payment.card_after
interest = card.balance * card.daily_periodic_rate * payment.days
new_balance = card.balance + interest
%__MODULE__{
payment
| card_after: %CreditCard{payment.card_after | balance: new_balance},
interest: interest
}
end
def apply_payment(payment, overpayment \\ 0) do
card = payment.card_after
minimum =
card.balance
|> min(card.minimum_floor)
|> max(card.balance * card.minimum_percent)
paid = min(card.balance, minimum + overpayment)
new_balance = card.balance - paid
%__MODULE__{
payment
| card_after: %CreditCard{payment.card_after | balance: new_balance},
minimum: minimum,
paid: paid
}
end
end
Another data structure and three public functions as the interface for manipulating it. The longest function finds the next payment date, because calendaring might be the only subject more complex credit cards. The interest calculation is right out of the formulas page I linked to above. Finally the actual payment function has to sort out whether we're paying the minimum percentage, using the minimum floor, or just paying the full remaining balance, depending on how much is left on the card.
That's enough for us to see how long it'll take to pay off an average debt if we stick to minimum payments:
payments =
CreditCard.new()
|> CreditCard.payment_schedule()
|> Enum.map(& &1.paid)
%{
months: length(payments),
total_paid: payments |> Enum.sum() |> Float.round(2)
}
|> IO.inspect()
%{months: 87414, total_paid: 6257806.8}
Over 7,000 years and $6,000,000? You would be forgiven for assuming that I have a bug! But do I?
Let's examine the first five payments:
CreditCard.new()
|> CreditCard.payment_schedule()
|> Enum.take(5)
|> IO.inspect()
[ %CreditCard.Payment{ days: 31, interest: 151.1539191780822, card_after: %CreditCard{ balance: 7281.550840794521, apr: 24.45, daily_periodic_rate: 6.698630136986302e-4, minimum_percent: 0.02, minimum_floor: 27.5, last_due: ~D[2023-11-16] }, card_before: %CreditCard{ balance: 7279.0, apr: 24.45, daily_periodic_rate: 6.698630136986302e-4, minimum_percent: 0.02, minimum_floor: 27.5, last_due: ~D[2023-10-16] }, minimum: 148.60307838356164, paid: 148.60307838356164 }, %CreditCard.Payment{ days: 30, interest: 146.32924771843236, card_after: %CreditCard{ balance: 7279.322486742694, apr: 24.45, daily_periodic_rate: 6.698630136986302e-4, minimum_percent: 0.02, minimum_floor: 27.5, last_due: ~D[2023-12-16] }, card_before: %CreditCard{ balance: 7281.550840794521, apr: 24.45, daily_periodic_rate: 6.698630136986302e-4, minimum_percent: 0.02, minimum_floor: 27.5, last_due: ~D[2023-11-16] }, minimum: 148.55760177025905, paid: 148.55760177025905 }, %CreditCard.Payment{ days: 31, interest: 151.1606158582637, card_after: %CreditCard{ balance: 7281.873440548939, apr: 24.45, daily_periodic_rate: 6.698630136986302e-4, minimum_percent: 0.02, minimum_floor: 27.5, last_due: ~D[2024-01-16] }, card_before: %CreditCard{ balance: 7279.322486742694, apr: 24.45, daily_periodic_rate: 6.698630136986302e-4, minimum_percent: 0.02, minimum_floor: 27.5, last_due: ~D[2023-12-16] }, minimum: 148.60966205201916, paid: 148.60966205201916 }, %CreditCard.Payment{ days: 31, interest: 151.21358833600186, card_after: %CreditCard{ balance: 7284.425288307241, apr: 24.45, daily_periodic_rate: 6.698630136986302e-4, minimum_percent: 0.02, minimum_floor: 27.5, last_due: ~D[2024-02-16] }, card_before: %CreditCard{ balance: 7281.873440548939, apr: 24.45, daily_periodic_rate: 6.698630136986302e-4, minimum_percent: 0.02, minimum_floor: 27.5, last_due: ~D[2024-01-16] }, minimum: 148.6617405776988, paid: 148.6617405776988 }, %CreditCard.Payment{ days: 29, interest: 141.50744522395203, card_after: %CreditCard{ balance: 7277.41407886057, apr: 24.45, daily_periodic_rate: 6.698630136986302e-4, minimum_percent: 0.02, minimum_floor: 27.5, last_due: ~D[2024-03-16] }, card_before: %CreditCard{ balance: 7284.425288307241, apr: 24.45, daily_periodic_rate: 6.698630136986302e-4, minimum_percent: 0.02, minimum_floor: 27.5, last_due: ~D[2024-02-16] }, minimum: 148.51865467062387, paid: 148.51865467062387 } ]
If you look closely at the first payment, you can see that the interest is a little more than what we're required to pay. Our balance actually went up by a couple of bucks!
The second payment was better, because it happened in a month that's one day shorter. It cost us less interest. After making both payments we're about 32 cents above where we started. So what saves us from a forever climbing balance?
February. Check out that fifth payment. Unfortunately it landed in a leap year, so it still has one more day than normal. However, it's enough of a win to undo the damage of the previous four payments and leave us over a buck and a half to the good.
This cycle repeats for a very long time, with each new February gaining a tiny bit of ground. Eventually we reach a point where all payments are more than the interest and a payoff can be reached. Even that is slow going though.
When you're in this situation, little changes can have a huge impact. Look at the differences we see if we just up the minimum payment percentage to the higher end of average:
payments =
CreditCard.new(minimum_percent: 0.03)
|> CreditCard.payment_schedule()
|> Enum.map(& &1.paid)
%{
months: length(payments),
total_paid: payments |> Enum.sum() |> Float.round(2)
}
|> IO.inspect()
%{months: 258, total_paid: 20606.42}
We're still paying for over 20 years and it'll cost nearly three times the amount charged, but this seems like a miracle compared to the previous outcome.
What if we tossed in an extra $10 per month?
card = CreditCard.new(minimum_percent: 0.03)
overpayments =
card
|> CreditCard.payment_schedule(10)
|> Enum.map(& &1.paid)
overpayments_total = overpayments |> Enum.sum() |> Float.round(2)
minimums_total =
card
|> CreditCard.payment_schedule()
|> Enum.map(& &1.paid)
|> Enum.sum()
%{
months: length(overpayments),
total_paid: overpayments_total,
savings: Float.round(minimums_total - overpayments_total, 2)
}
|> IO.inspect()
%{months: 178, total_paid: 17481.32, savings: 3125.1}
That saves us over six years and $3,000 even with the increased outlay of cash each month.
Hopefully you get the idea. I think math holds a lot of value to humans in general, regardless of where we land on the programming issue. Just refreshing algebra and geometry can do a lot for you, if it's been a while. In a world where typical credit card debt turns into a life long reduction of income, this stuff matters!