SimpleFIN Liability Balance Normalization#
SimpleFIN reports all liability balances (credit cards and loans) as negative from a banking-ledger perspective, regardless of whether the account reflects money owed or an overpayment credit. The app's internal convention is the inverse: positive = debt owed, negative = credit/overpayment. Bridging these two conventions requires type-specific logic in SimplefinAccount::Processor#process_account!.
Sign Convention#
| State | SimpleFIN sends | App stores |
|---|---|---|
| Debt owed | negative (e.g. -500) | positive (+500) |
| Overpayment / credit | negative (e.g. -50) | negative (-50) |
| Loan principal outstanding | positive (e.g. +100000) | positive (+100000) |
The conversion was introduced in PR #410 and then progressively refined. Loans and credit cards are handled differently because their semantics differ — loans do not have a credit state analogous to a credit-card overpayment.
Loan Accounts#
Loans are short-circuited before any heuristic runs . The processor stores observed.abs directly. Rationale (from PR #1574):
- Banks report principal outstanding as a positive value; a paid-off loan has balance
0, never negative. - Loans rarely have enough transaction history for the heuristic to fire. Without the short-circuit, the
:unknownfallback path would negate the value and inflate net worth by2 × principal.
Credit Card Accounts — OverpaymentAnalyzer#
For CreditCard accounts, SimplefinAccount::Liabilities::OverpaymentAnalyzer is invoked first . It returns a Result struct with one of three classifications:
| Classification | Stored balance | Condition |
|---|---|---|
:debt | +observed.abs | charges exceed payments beyond epsilon |
:credit | -observed.abs | payments exceed charges by at least the observed balance |
:unknown | falls back to normalize_liability_balance | insufficient evidence |
How the Analyzer Works#
-
Transaction gathering : Pulls the last 120 days of
Entryrecords for the linked account (app convention: charges > 0, payments < 0). Falls back to raw provider transactions, negating amounts to convert from banking convention. -
Minimum evidence gate : Requires ≥ 10 transactions (
min_txns). Returns:unknownif below threshold. -
Statement boundary guard : If there is a very recent payment (within 5 days) with ≤ 2 total payments, returns
:unknownto avoid false credit classification just after a statement payment posts before charges catch up. -
Net-balance sanity check : If
|computed_net − observed|exceeds 10% of the observed balance (minimum $5), returns:unknown— guards against incomplete transaction history (e.g., pending charges). Added in PR #642. -
Classification : Uses epsilon =
max($0.50, observed × 0.5%)to avoid misclassifying near-zero-difference accounts. -
Sticky caching : Confirmed classifications are cached for 7 days (key:
simplefin:sfa:<id>:liability_sign_hint) to prevent sign flapping between syncs.
Fallback — normalize_liability_balance#
When the analyzer returns :unknown or raises an error, normalize_liability_balance applies sign-only logic:
- If both
current_balanceandavailable_balanceare positive: store as-observed.abs(treat as debt, negate). - If both are negative: store as
+observed.abs. - Otherwise: store as
-observed(simple negation).
Configuration#
The overpayment heuristic is enabled by default. It can be toggled or tuned via Setting records (checked first) or environment variables :
| Setting key | Env var | Default |
|---|---|---|
simplefin_cc_overpayment_detection | SIMPLEFIN_CC_OVERPAYMENT_HEURISTIC | true |
simplefin_cc_overpayment_window_days | — | 120 |
simplefin_cc_overpayment_min_txns | — | 10 |
simplefin_cc_overpayment_min_payments | — | 2 |
simplefin_cc_overpayment_epsilon_base | — | $0.50 |
simplefin_cc_overpayment_statement_guard_days | — | 5 |
simplefin_cc_overpayment_sticky_days | — | 7 |
Decision History#
| PR | Change |
|---|---|
| #410 | Introduced basic sign inversion for all liabilities |
| #412 | Added OverpaymentAnalyzer with heuristic classification and sticky caching |
| #642 | Added net-balance sanity check to guard against incomplete transaction history |
| #1574 | Short-circuited Loan accounts to bypass heuristic and avoid principal inversion |
Key Source Files#
SimplefinAccount::Processor— orchestrates all balance normalization; liability sign logic inprocess_account!(lines 81–168)SimplefinAccount::Liabilities::OverpaymentAnalyzer— full heuristic implementation