Finance Debts
How debts (credit cards, installment loans, and other liabilities) are modeled, the per-type validation and amortization rules, and how clients call the debts API.
Overview
A debt is a per-user record of an outstanding balance with an APR, optional minimum payment and due day, and an optional link to an Account. The server validates per-type constraints (credit card vs. installment loan vs. other), computes amortization schedules and payoff projections, and reflects debt payments as EXPENSE transactions tagged to the debt. Debts are soft-deleted (Status.INACTIVE) and excluded from list / progress / summary queries.
Three debt types are supported, each with different required fields and progress semantics:
| Type | Required | Progress metric | Notes |
|---|---|---|---|
CREDIT_CARD | creditLimit | utilizationPercentage = currentBalance / creditLimit | originalPrincipal defaults to creditLimit when unset; account link must be a CREDIT_CARD account; currentBalance must not exceed creditLimit. |
INSTALLMENT_LOAN | originalPrincipal > 0 | percentagePaid = (originalPrincipal − currentBalance) / originalPrincipal | Account link must be a LOAN account. |
OTHER | — | percentagePaid | No account-type restriction. |
Architecture
Clients call REST endpoints. DebtService is the source of truth for per-type rules and payoff math; TransactionService calls back into DebtService.applyTransactionPayment / revertTransactionPayment whenever a debt-linked transaction is created, updated, or deleted.
Data model
debts table (Exposed UUIDTable, Debt.kt):
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
name | VARCHAR(100) | required, non-blank |
debt_type | VARCHAR(20) | CREDIT_CARD / INSTALLMENT_LOAN / OTHER; default INSTALLMENT_LOAN |
original_principal | DECIMAL(10,2) | required > 0 for loans; defaults to credit_limit for credit cards when unset |
current_balance | DECIMAL(10,2) | ≥ 0; ≤ credit_limit for credit cards |
credit_limit | DECIMAL(10,2) nullable | required > 0 when debt_type = CREDIT_CARD |
interest_rate | DECIMAL(6,3) default 0 | APR percentage (e.g. 18.5 = 18.5%). Range 0.0 .. 999.999 |
minimum_payment | DECIMAL(10,2) nullable | |
due_day_of_month | INTEGER nullable | client may pass any value; server clamps to 1..28 when computing payment dates |
target_payoff_date | DATE nullable | |
account_id | UUID FK → accounts (SET_NULL) | optional; type must match per DebtTypeRules.validateAccountType |
user_id | UUID FK → users (CASCADE) | |
status | VARCHAR(10) default ACTIVE | ACTIVE / INACTIVE (soft-delete) |
Transactions tagged to a debt carry a nullable debt_id FK with SET_NULL on delete. The link is only valid for EXPENSE transactions in the DEBT category (enforced by DebtService.isValidDebtLink).
The base table is created by JdbcSchemaBootstrap via Exposed SchemaUtils. Subsequent column changes ship as Flyway migrations:
V22__debt_type_and_credit_limit.sql— addsdebt_type(NOT NULL DEFAULT'INSTALLMENT_LOAN') andcredit_limit.V23__debt_interest_rate_precision.sql— widensinterest_ratefromDECIMAL(5,4)toDECIMAL(6,3)so APR can be stored as a percentage instead of a fraction.
Per-type rules
Centralized in DebtTypeRules. Both create and update paths go through validateCreate / validateUpdate; failures throw IllegalArgumentException → 400 Bad Request with the message in ErrorResponse.
| Check | Where |
|---|---|
Interest rate in 0.0 .. 999.999 | validateInterestRate |
| Name non-blank | validateCreate |
CC needs creditLimit > 0, 0 ≤ currentBalance ≤ creditLimit | validateCreate / validateUpdate |
Loan needs originalPrincipal > 0, currentBalance ≥ 0 | validateCreate / validateUpdate |
| Account link type matches debt type | validateAccountType |
| CC original principal defaults to credit limit | resolvedOriginalPrincipal |
Map account balance to debt balance: CC → abs(min(0, accountBalance)), others → max(0, accountBalance) | mapAccountBalanceToDebtBalance |
Utilization = currentBalance / creditLimit × 100 (nullable) | computeUtilization |
Amortization & payoff
All payoff math lives in DebtAmortizationCalculator. MAX_PERIODS = 600 caps the projection at 50 years.
generateSchedulereturnsScheduleResult { entries, totalInterest, payoffDate, paymentTooLow }.paymentTooLowis set when the monthly payment fails to cover monthly interest or the 600-period cap is hit.computeProgressis what/finance/debts/{id}/progress(andgetAllWithProgress) returns: derivedpercentagePaid,monthsRemaining,totalInterestRemaining,paymentTooLow. IfmonthlyPaymentis null, it falls back toestimateMinimumPayment.estimateMinimumPayment— 60-month standard amortization formula when APR > 0; otherwisebalance / 60floored at $1.firstPaymentDate(reference, dueDayOfMonth)— clampsdueDayOfMonthto1..28and returns the next occurrence on/afterreference.
Payment recording & balance sync
Two side-effect-heavy paths cross-link debts with transactions and accounts:
POST /finance/debts/{id}/payment— creates anEXPENSEtransaction in theDEBTcategory linked to the debt.DebtRepository.recordPaymentdefers toTransactionRepository, which in turn callsDebtService.applyTransactionPaymentto reducecurrent_balance. Deleting or unlinking the transaction reverts the balance change viarevertTransactionPayment.POST /finance/debts/{id}/sync-balance— only meaningful for credit cards with anaccount_id. Reads the linked account’s balance (computed viaAccountBalanceCheckpointService— see accounts.md) and maps it tocurrent_balanceviaDebtTypeRules.mapAccountBalanceToDebtBalance. For credit cards this means a negative account balance becomes a positive owed amount.
API
All routes are under route("/finance") { route("/debts") { … } } in FinanceRouting.kt:939-1089. Authentication: LoggedUserDTO principal; every route filters by userId.
| Method | Path | Request | Response |
|---|---|---|---|
GET | /finance/debts | ?includeProgress=true (optional) | DebtResponseDTO[] or DebtProgressResponseDTO[] |
GET | /finance/debts/{id}/progress | — | DebtProgressResponseDTO or 404 |
GET | /finance/debts/{id}/amortization-schedule | ?monthlyPayment={double} (optional) | DebtAmortizationScheduleResponseDTO or 404 |
GET | /finance/debts/{id}/transactions | — | TransactionResponseDTO[] |
POST | /finance/debts | CreateDebtDTO | 201 Created + DebtResponseDTO; 400 on validation failure |
PATCH | /finance/debts/{id} | UpdateDebtDTO (set clearAccountId=true to unlink) | 200 OK + DebtResponseDTO; 400/404 |
DELETE | /finance/debts/{id} | — | 200 OK (soft-delete: status = INACTIVE) |
POST | /finance/debts/{id}/payment | RecordDebtPaymentDTO | 201 Created; 400 if the link is invalid |
POST | /finance/debts/{id}/sync-balance | — | DebtProgressResponseDTO or 404; 400 if debt has no linked account |
Key DTOs (defined in FinanceDTOs.kt:139-220):
CreateDebtDTO— all fields optional exceptnameandtype; defaults:type=INSTALLMENT_LOAN,originalPrincipal=0.0,interestRate=0.0.UpdateDebtDTO— every field nullable plusclearAccountId: Boolean = false.DebtResponseDTO— flat read shape: id, name, type, principals/balances, APR, minimum, dueDay, targetPayoffDate, accountId.DebtProgressResponseDTO— wrapsDebtResponseDTOand addspercentagePaid,remainingAmount,estimatedPayoffDate,monthsRemaining,totalInterestRemaining,paymentTooLow,utilizationPercentage.AmortizationEntryDTO—period,date(DD/MM/YYYY),payment,principal,interest,remainingBalance.DebtAmortizationScheduleResponseDTO—debtId,monthlyPayment,entries[],totalInterest,payoffDate,paymentTooLow.RecordDebtPaymentDTO—amount,accountId, optionaldescription, optionaldate(defaults to today in the server’s default timezone, formattedDD/MM/YYYY).
Client mapping
| Platform | Entry point | Status |
|---|---|---|
| Shared models | shared/.../oter/finance/model/Debt.kt | mirrors the server DTOs as @Serializable data classes |
| Shared HTTP client | FinanceGoalService.kt:50-101 | all 9 endpoints |
| Shared ViewModel | DebtViewModel.kt | StateFlow-based |
| Compose UI | DebtTracker.kt, DebtForm.kt, RecordDebtPaymentDialog.kt, DebtAmortizationTable.kt | wired up on Android, iOS, Desktop JVM via FinanceTab.DEBTS (index 4) |
| Desktop ViewModel | composeApp/.../ui/viewmodels/DebtViewModel.kt | lifecycle-aware wrapper |
| Web | web-frontend/src/lib/api/finance.ts | not implemented — see Known gaps |
Compose i18n: shared/src/commonMain/composeResources/values/strings.xml:638-674 (en) + matching keys in values-es/strings.xml.
Known gaps
- Web frontend has no debt UI.
FinanceTabinweb-frontend/src/app/[locale]/finance/page.tsx:137is'ACCOUNTS' | 'TRANSACTIONS' | 'SCHEDULED' | 'BUDGETS' | 'STATISTICS' | 'PREDICTION'—DEBTSis missing.lib/api/finance.tshas no Debt types orfinanceApi.*methods.DEBTonly appears as a transaction-category string. (Distinct concept from the new debt feature.) - No reminders.
due_day_of_monthis stored and used in payoff math but never surfaces as a notification, calendar entry, or dashboard alert. The notifications infra exists (NotificationDispatcher,NotificationType,NotificationThrottleService, theScheduledTransactionCheckerServicebackground-loop pattern) but isn’t wired for debts. (calendar/*modules in the repo contain only build artifacts, so a calendar entry is not a viable target today.) - No aggregate summary. No “total debt” / “weighted avg APR” / “next 3 due dates” endpoint or summary card. Clients can derive most of it from the list but don’t.
- No payoff-strategy planner (Avalanche / Snowball). Listed in Roadmap §148 (“Debt payoff scenarios”) but not implemented.
paymentTooLowwarning is buried. Surfaces only inside the expanded amortization panel inDebtItem; not visible on the debt list summary.- Amortization is table-only. No chart visualization (balance over time, principal vs interest).
rechartsis available on web; Compose currently has only the desktop-sideBalanceChartCanvas precedent. - No bulk import / export. No CSV import for existing debts and no schedule export.
Testing
- Unit (per-type rules):
DebtTypeRulesTest— covers credit-card limits, loan principal, account-type pairing, utilization, APR bounds. - Unit (amortization):
DebtAmortizationCalculatorTest— zero balance, zero APR paydown, typical loan, payment-too-low detection, payoff completion. - DB integration:
DebtSyncBalanceTest— exercises the credit-card account → debt balance sync end-to-end.
No client tests yet for debt UIs.