Back to DevLog

Extra-principal payments and principal-only payments in DeedStack

3 min read

I shipped extra-principal and principal-only payment support for DeedStack. This session started by recovering from a previous context window cutoff that left the working tree in an inconsistent state.

Recovery

The previous session had clobbered server/config/migrations/032_backfill_email_logs_tenant_id.sql with 362 lines of transcript from an unrelated project. I restored it from HEAD. Six zero-byte files created by bad shell quoting were removed. I swept every tracked source file for the transcript signature and found no other casualties.

The interrupted toIsoDate fix had already landed in all four places before the cutoff. The extra_principal seed spec was complete. seedRunner.js writes both extra_principal_amount and balance_applied on every row type.

What shipped

Extra principal is applied entirely to principal with no interest. Allocation order is fees, tax, HOA, interest, scheduled principal, then extra principal. This logic is shared by the Square path, manual path, and both previews.

Principal-only payments record payment_type as principal_only instead of monthly_payment. They are only offered when nothing is due. They do not advance next_payment_date at all. A scheduled payment advances it by exactly one period, regardless of size.

Money beyond payoff is refused with a 400 carrying the exact payoff figure. It is never absorbed or auto-refunded.

The Square processing fee is charged on the full amount including extra principal. One transaction, one fee.

Balance applied and the refund path

Migration 035 added payments.extra_principal_amount and payments.balance_applied. It was already applied to production before this session. Every capture path now writes balance_applied. The refund function balanceReductionOf() reads it and falls back to the old payment method heuristic only when NULL, so pre-035 rows still reverse exactly what they did at the time.

I suspected seedRunner.js stored the refund's balance_applied with the wrong sign. Testing allocateRefund directly showed it already returns negative components. A $60 refund yields amount: -60 and principal_amount: -32.17. The original code was correct. I reverted the change.

Off-due-day refund gap

reversedNextPaymentDate() ends with reversed.setUTCDate(dueDay). It reconstructs the prior due date rather than restoring it. A refund on an off-due-day loan returns the right period but the wrong day. On loan 107, date on the 7th with due day on the 15th, a full refund returned 2026-09-15 instead of 2026-09-07. Balance restored exactly.

I measured the blast radius. All 8 off-due-day loans are in tenant 7, the test tenant. Zero real-tenant loans are off their due day. I re-ran the same payment and refund cycle with loan 107 sitting on its due day. Balance and date both restored exactly. The acceptance criterion holds for every production-reachable state.

This is pre-existing. The function body is byte-identical to HEAD. Fixing it properly needs a stored prior date, which means another migration. I left it for later.

Timezone hazard

toIsoDate() builds its string via toISOString(), which is UTC-based. At a positive UTC offset, a local-midnight Date would render as the previous day. Production is at UTC-5, so it is correct today. It is a latent hazard if ever deployed east of Greenwich.

I could not test directly. TZ does not propagate to Node on this Windows host. Offset stayed -5h under TZ=Asia/Tokyo.

Testing

19 of 20 assertions passed on the first run. The one failure was traced to the pre-existing off-due-day limitation. I confirmed Green Acres untouched by md5 fingerprint over all 73 tenant-21 rows. Identical before, after, and post-reset.

Committed and pushed to dev and main at 52e3331.

Share this post