Splitting a lump-sum loan payment without losing a cent
Designing a predictable allocation algorithm that distributes one payment across multiple loans, reconciles rounding and directs any surplus toward principal.
Eberechi Omeje··4 min read
Product Engineering
Splitting a lump-sum loan payment without losing a cent
Engineering field note by Eberechi Omeje
A single payment can represent several different financial actions. In a loan-tracking application, one lump sum may need to cover the scheduled amount on multiple loans and then apply the remaining money as an extra principal payment.
The form is the easy part. The harder problem is making the allocation predictable, ensuring the stored rows add back to the original amount and keeping the preview consistent with what is eventually saved.
This field note describes the domain logic behind that feature. It uses generalized examples and contains no real balances, lender information, account details or database identifiers.
Define the business rule before the algorithm
The allocation follows four rules:
The user selects one or more editable loans.
The payment first covers each selected loan's scheduled monthly amount.
If the payment is smaller than the combined schedule, it is split proportionally.
If the payment is larger, the remainder becomes an extra principal payment on a loan chosen by the user.
That definition removes an important ambiguity. A surplus is not distributed accidentally across every loan; it has one explicit principal target.
Calculate in cents
Currency allocation should not depend on repeated floating-point arithmetic. Inputs and scheduled amounts are converted to integer cents before the split:
function toCents(value) {
const amount = Number(value);
if (!Number.isFinite(amount)) return NaN;
return Math.round(amount * 100);
}
The function rejects non-positive totals and invalid scheduled payments before constructing any database rows. Duplicate loan identifiers are also removed so the same obligation cannot receive two allocations because of repeated input.
The browser restricts input to two decimal places, while the allocation function performs its own validation instead of trusting the form.
Handle a short payment fairly
Suppose the selected loans have different monthly obligations and the available payment cannot cover all of them. Giving every loan the same amount would ignore those differences.
The algorithm calculates each loan's proportional share of the available regular-payment budget. Each exact share may contain a fraction of a cent, so the initial allocation is rounded down.
Rounding down creates a small remainder. Those cents are distributed in descending order of fractional remainder:
let centsLeft =
regularBudgetCents -
allocations.reduce(
(sum, loan) => sum + loan.allocatedCents,
0,
);
for (let index = 0; centsLeft > 0; index += 1, centsLeft -= 1) {
remainderOrder[index % remainderOrder.length].allocatedCents += 1;
}
This is a largest-remainder allocation. It ensures that the final cent values reconcile exactly to the payment while staying as close as possible to the proportional result.
Model regular and extra payments separately
When the total exceeds the combined monthly obligations, the regular allocations are created first. The surplus is then represented as a separate extra-payment event for the selected target loan.
That distinction feeds the rest of the application:
regular payments participate in scheduled-payment status;
extra payments reduce principal;
actual history can explain what happened; and
forecasts can include recorded principal reductions when estimating payoff.
Keeping the event types separate avoids hiding principal-only behaviour inside an ordinary monthly payment.
Use one function for preview and persistence
The allocation logic is a pure function. The form calls it to produce a live payment preview, and the save handler calls the same function again before inserting the resulting rows.
This prevents a common UI failure: showing one calculation to the user but reproducing the logic differently when saving.
The preview lists every regular allocation, identifies the extra-payment target and confirms the total. If the inputs do not form a valid allocation, no preview is shown and the submission returns a specific validation message.
Revalidate ownership before writing
A list displayed in the browser is not an authorization boundary. Immediately before saving, the selected identifiers are matched against loans owned by the signed-in user.
If the validated set does not contain every submitted identifier, the operation stops. Database row-level security remains the final protection, while the server-side check provides a clearer application error and prevents invalid rows from being prepared.
The allocations are inserted together so the payment is not intentionally saved as a collection of unrelated form submissions.
Hardening cases
The most valuable automated tests for this function are table-driven examples covering:
a payment equal to the scheduled total;
a payment smaller than the total;
fractional-cent remainders;
a payment with an extra-principal remainder;
duplicate selections;
zero or invalid scheduled amounts; and
a submitted loan that the current user cannot edit.
The allocation function was kept independent of React and the database specifically so these cases can be exercised without rendering the application.
What I would carry into the next financial feature
Three decisions made the feature easier to reason about:
Represent money in the smallest unit during allocation.
Keep the business rule in one pure function used by both preview and save.
Represent meaning in the data model. A regular payment and an extra principal payment may occur together, but they are not the same event.
The broader lesson is that small financial features deserve explicit rules. A few cents of unexplained drift can undermine trust in the entire product.