Skip to Content

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:

TypeRequiredProgress metricNotes
CREDIT_CARDcreditLimitutilizationPercentage = currentBalance / creditLimitoriginalPrincipal defaults to creditLimit when unset; account link must be a CREDIT_CARD account; currentBalance must not exceed creditLimit.
INSTALLMENT_LOANoriginalPrincipal > 0percentagePaid = (originalPrincipal − currentBalance) / originalPrincipalAccount link must be a LOAN account.
OTHERpercentagePaidNo 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):

ColumnTypeNotes
idUUID PK
nameVARCHAR(100)required, non-blank
debt_typeVARCHAR(20)CREDIT_CARD / INSTALLMENT_LOAN / OTHER; default INSTALLMENT_LOAN
original_principalDECIMAL(10,2)required > 0 for loans; defaults to credit_limit for credit cards when unset
current_balanceDECIMAL(10,2)≥ 0; ≤ credit_limit for credit cards
credit_limitDECIMAL(10,2) nullablerequired > 0 when debt_type = CREDIT_CARD
interest_rateDECIMAL(6,3) default 0APR percentage (e.g. 18.5 = 18.5%). Range 0.0 .. 999.999
minimum_paymentDECIMAL(10,2) nullable
due_day_of_monthINTEGER nullableclient may pass any value; server clamps to 1..28 when computing payment dates
target_payoff_dateDATE nullable
account_idUUID FK → accounts (SET_NULL)optional; type must match per DebtTypeRules.validateAccountType
user_idUUID FK → users (CASCADE)
statusVARCHAR(10) default ACTIVEACTIVE / 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:

Per-type rules

Centralized in DebtTypeRules. Both create and update paths go through validateCreate / validateUpdate; failures throw IllegalArgumentException400 Bad Request with the message in ErrorResponse.

CheckWhere
Interest rate in 0.0 .. 999.999validateInterestRate
Name non-blankvalidateCreate
CC needs creditLimit > 0, 0 ≤ currentBalance ≤ creditLimitvalidateCreate / validateUpdate
Loan needs originalPrincipal > 0, currentBalance ≥ 0validateCreate / validateUpdate
Account link type matches debt typevalidateAccountType
CC original principal defaults to credit limitresolvedOriginalPrincipal
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.

  • generateSchedule returns ScheduleResult { entries, totalInterest, payoffDate, paymentTooLow }. paymentTooLow is set when the monthly payment fails to cover monthly interest or the 600-period cap is hit.
  • computeProgress is what /finance/debts/{id}/progress (and getAllWithProgress) returns: derived percentagePaid, monthsRemaining, totalInterestRemaining, paymentTooLow. If monthlyPayment is null, it falls back to estimateMinimumPayment.
  • estimateMinimumPayment — 60-month standard amortization formula when APR > 0; otherwise balance / 60 floored at $1.
  • firstPaymentDate(reference, dueDayOfMonth) — clamps dueDayOfMonth to 1..28 and returns the next occurrence on/after reference.

Payment recording & balance sync

Two side-effect-heavy paths cross-link debts with transactions and accounts:

  • POST /finance/debts/{id}/payment — creates an EXPENSE transaction in the DEBT category linked to the debt. DebtRepository.recordPayment defers to TransactionRepository, which in turn calls DebtService.applyTransactionPayment to reduce current_balance. Deleting or unlinking the transaction reverts the balance change via revertTransactionPayment.
  • POST /finance/debts/{id}/sync-balance — only meaningful for credit cards with an account_id. Reads the linked account’s balance (computed via AccountBalanceCheckpointService — see accounts.md) and maps it to current_balance via DebtTypeRules.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.

MethodPathRequestResponse
GET/finance/debts?includeProgress=true (optional)DebtResponseDTO[] or DebtProgressResponseDTO[]
GET/finance/debts/{id}/progressDebtProgressResponseDTO or 404
GET/finance/debts/{id}/amortization-schedule?monthlyPayment={double} (optional)DebtAmortizationScheduleResponseDTO or 404
GET/finance/debts/{id}/transactionsTransactionResponseDTO[]
POST/finance/debtsCreateDebtDTO201 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}/paymentRecordDebtPaymentDTO201 Created; 400 if the link is invalid
POST/finance/debts/{id}/sync-balanceDebtProgressResponseDTO or 404; 400 if debt has no linked account

Key DTOs (defined in FinanceDTOs.kt:139-220):

  • CreateDebtDTO — all fields optional except name and type; defaults: type=INSTALLMENT_LOAN, originalPrincipal=0.0, interestRate=0.0.
  • UpdateDebtDTO — every field nullable plus clearAccountId: Boolean = false.
  • DebtResponseDTO — flat read shape: id, name, type, principals/balances, APR, minimum, dueDay, targetPayoffDate, accountId.
  • DebtProgressResponseDTO — wraps DebtResponseDTO and adds percentagePaid, remainingAmount, estimatedPayoffDate, monthsRemaining, totalInterestRemaining, paymentTooLow, utilizationPercentage.
  • AmortizationEntryDTOperiod, date (DD/MM/YYYY), payment, principal, interest, remainingBalance.
  • DebtAmortizationScheduleResponseDTOdebtId, monthlyPayment, entries[], totalInterest, payoffDate, paymentTooLow.
  • RecordDebtPaymentDTOamount, accountId, optional description, optional date (defaults to today in the server’s default timezone, formatted DD/MM/YYYY).

Client mapping

PlatformEntry pointStatus
Shared modelsshared/.../oter/finance/model/Debt.ktmirrors the server DTOs as @Serializable data classes
Shared HTTP clientFinanceGoalService.kt:50-101all 9 endpoints
Shared ViewModelDebtViewModel.ktStateFlow-based
Compose UIDebtTracker.kt, DebtForm.kt, RecordDebtPaymentDialog.kt, DebtAmortizationTable.ktwired up on Android, iOS, Desktop JVM via FinanceTab.DEBTS (index 4)
Desktop ViewModelcomposeApp/.../ui/viewmodels/DebtViewModel.ktlifecycle-aware wrapper
Webweb-frontend/src/lib/api/finance.tsnot 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. FinanceTab in web-frontend/src/app/[locale]/finance/page.tsx:137 is 'ACCOUNTS' | 'TRANSACTIONS' | 'SCHEDULED' | 'BUDGETS' | 'STATISTICS' | 'PREDICTION'DEBTS is missing. lib/api/finance.ts has no Debt types or financeApi.* methods. DEBT only appears as a transaction-category string. (Distinct concept from the new debt feature.)
  • No reminders. due_day_of_month is stored and used in payoff math but never surfaces as a notification, calendar entry, or dashboard alert. The notifications infra exists (NotificationDispatcher, NotificationType, NotificationThrottleService, the ScheduledTransactionCheckerService background-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.
  • paymentTooLow warning is buried. Surfaces only inside the expanded amortization panel in DebtItem; not visible on the debt list summary.
  • Amortization is table-only. No chart visualization (balance over time, principal vs interest). recharts is available on web; Compose currently has only the desktop-side BalanceChart Canvas 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.