Aivinn Wellness Score: A Behavior-Driven Financial Health Metric — Case Study

Table of Contents

  1. Background & Problem Statement
  2. System Design Decisions
  3. Score Composition
  4. Trigger-Based Recalculation
  5. Component Deep Dive
  6. Score Bounds & the Clamp
  7. Read-Time Scoring & Labeling
  8. Push Notification Thresholds
  9. Known Trade-offs & Lessons Learned

Background & Problem Statement

The Wellness Score is a single composite number (60–100) meant to summarize a user's financial health at a glance — the kind of metric a user checks the way they'd check a credit score, without needing to interpret five separate charts.

Leadership's original specification called for a more granular, continuously-responsive calculation — score movement was expected to track behavior closely and smoothly, cycle over cycle. During implementation, that spec was simplified into a coarser, bucket-based model: five independent sub-scores, each computed from a small number of discrete rules, summed into a final total. The simplification made the logic easy to reason about, easy to test, and cheap to compute on every relevant event.

That trade-off surfaced later as a real product tension: after launch, the score was reported as feeling "stuck" — not reflecting day-to-day changes in user behavior the way the original spec implied it should. This document describes the system as actually implemented, and calls out explicitly where that implementation diverges from a continuous-movement model, since that divergence is the root of the discrepancy.


System Design Decisions

Decision 1: Five independent weighted components, not one formula

Rather than a single opaque calculation, the score is split into five components — Habit, Budget, Income Stability, Goal, Challenge — each computed independently and summed.

Why: independent components are individually testable, individually explainable to a user ("your budget adherence dropped, that's why"), and can be recomputed selectively depending on what triggered the update (see Decision 2). A single monolithic formula would make all three of those harder.

Decision 2: Trigger-scoped partial recomputation

The score is not recalculated wholesale on every relevant action. Instead, each trigger recomputes only the components it's actually relevant to:

TriggerHabitBudgetIncome StabilityGoalChallenge
TRANSACTION
GOAL
CHALLENGE

Why: a transaction plausibly affects habit, budget, income, and goal progress all at once, so it's worth recomputing all four together. But a challenge completion has no bearing on, say, budget adherence — recomputing it anyway would just mean extra DB queries for a value that hasn't actually changed. Non-recomputed components simply retain their last stored value.

Trade-off worth naming: this means a challenge completion never re-evaluates habit/budget/income in the same request. If the CEO's mental model was "any positive action should feel like it moves the whole score," trigger-scoping breaks that intuition, even though each individual number stays technically correct.

Decision 3: Step-function buckets over continuous scaling

Budget Score and Income Stability Score are both computed from a spend-to-budget (or spend-to-income) ratio, but mapped onto a fixed 6-level scale instead of a continuous curve:

≤1.00 → 20
≤1.10 → 18
≤1.25 → 16
≤1.50 → 14
≤1.75 → 12
 >1.75 → 10

Goal Score and Challenge Score go further, with only 3 levels each (based on count of fully-completed items: 0 / 1 / 2+).

Why: discrete buckets are simple to reason about and simple to explain in a notification ("you crossed into the next tier"). The trade-off is the one at the center of the CEO's feedback: two users with meaningfully different spending ratios (say, 1.02x vs 1.09x budget) score identically, because both land in the same bucket. The score only visibly moves when a threshold is crossed, not proportionally to the underlying behavior.

Decision 4: "No data" defaults to best case, not neutral

Loading code snippet…

Why: the reasoning at implementation time was to avoid penalizing users for incomplete setup (e.g. a brand-new wallet with no budget configured yet shouldn't look "unhealthy"). In practice, this means disengagement (not setting a budget, not logging income) is scored identically to ideal behavior (staying perfectly within budget) — which is a real source of the "score doesn't move" perception, since it can mask genuinely absent data as a maxed-out component.

Decision 5: Binary completion credit — no partial credit

Goal Score and Challenge Score only count fully completed items. A savings goal at 95% progress contributes exactly the same as one at 0%.

Why: completion is unambiguous and cheap to compute (a count query), whereas partial-progress weighting would require normalizing across goal types (SAVING_GOAL vs BUDGET_LIMIT) with very different progress semantics. The cost is that a user who is actively, visibly making progress toward a goal sees zero score movement from that effort until the moment of completion.

Decision 6: Fixed 60–100 range that mirrors the components' natural bounds

Loading code snippet…

The five components' individual min/max values sum to exactly 60 and exactly 100:

ComponentMinMax
Habit2030
Budget1020
Income Stability1020
Goal1015
Challenge1015
Total60100

Why this matters: the clamp(60, 100) never actually clips a real computed value — it's a defensive floor/ceiling that happens to be mathematically redundant given the component ranges. Worth knowing so nobody mistakes it for an active constraint shaping score behavior; it isn't one.


Score Composition

Final Score = Habit + Budget + Income Stability + Goal + Challenge

Default values used when no wellness_score row exists yet (new wallet):

Loading code snippet…

This produces a starting score of 80 for a brand-new wallet with zero activity — landing directly in the "On Track" band before the user has done anything. This is a direct downstream consequence of Decision 4 (no-data-as-best-case), applied uniformly across all five defaults.


Trigger-Based Recalculation

Loading code snippet…

The four TRANSACTION-triggered computations run in parallel via Promise.all, since they're independent queries against the same wallet — no reason to serialize them.


Component Deep Dive

Habit Score (20–30)

Loading code snippet…

Counts distinct active days in a rolling 10-day window, not transaction volume. A user logging one transaction a day and one logging ten transactions a day score identically — the metric rewards consistency of engagement, not intensity.

Budget Score (10–20)

Loading code snippet…

Bound to the user's current pay cycle (computeCycle(payday)), not a calendar month — so score movement is anchored to when the user actually gets paid, not an arbitrary date boundary.

Income Stability Score (10–20)

Loading code snippet…

Structurally identical to Budget Score — same bucket thresholds, same shape — just measured against income instead of a set budget. This symmetry was intentional: a user who spends within a self-set budget and within their actual income gets rewarded on both axes, rather than one subsuming the other.

Goal Score (10–15)

Loading code snippet…

Notably, BUDGET_LIMIT-type goal transactions are only fetched if at least one such goal exists (budgetLimitGoals.length > 0) — a cheap short-circuit avoiding an unnecessary query for wallets that only use SAVING_GOAL-type goals.

Challenge Score (10–15)

Loading code snippet…

Counts lifetime completions, not per-cycle — meaning this component is monotonic and can only increase over time (barring a manual data correction). Unlike every other component, it has no mechanism to decrease.


Score Bounds & the Clamp

Loading code snippet…

As shown in Decision 6, this clamp is mathematically a no-op given the component ranges — but it's kept in place as a defensive guard against any future change to a component's range accidentally producing an out-of-bounds total.


Read-Time Scoring & Labeling

A separate, read-only function computes the score on demand — using the same five compute functions, run in parallel — without persisting anything. This is what powers any "check your score right now" view that shouldn't wait for a trigger event.

Loading code snippet…

Why a duplicate calculation path instead of just reading the last persisted score: the persisted wellness_score row can lag behind reality between triggers (e.g. right after a pay cycle rolls over, before any new transaction fires a recompute). getWellnessScore guarantees an always-fresh number at the cost of recomputing all five components on every call — acceptable since it's read-only and not on any hot path that fires per keystroke.

Notable duplication: the clamp logic and bucket thresholds are re-implemented independently in this function rather than reusing the persisted-write path's helpers. This is worth flagging as technical debt — a future change to a threshold would need to be applied in both places.


Push Notification Thresholds

Fired only from the persisted-write path (recalculateWellnessScore), not from the read-only getWellnessScore:

Loading code snippet…

Both are edge-crossing checks, not level checks — they fire once, at the moment of crossing, and won't re-fire while the score sits on one side of the threshold. previousScore !== null guards against firing on a brand-new wallet's first-ever score (there's no "previous" to have crossed from).


Known Trade-offs & Lessons Learned

  1. The core tension is spec vs. implementation, not a bug. The original leadership spec implied continuous, granular movement; the implementation is a small number of discrete buckets per component. Both are internally consistent — they're just different products. Documenting this explicitly (rather than treating "the score doesn't move" as a defect to silently patch) was the most useful output of this write-up.

  2. "No data = best case" is the single biggest contributor to perceived staleness. Two of five components (Budget, Income Stability) return their maximum score when there's simply no data to evaluate, rather than a neutral or lowest value. This was a reasonable choice to avoid punishing incomplete onboarding, but it means disengaged users and ideal users can be indistinguishable on those two components.

  3. Binary completion credit trades responsiveness for simplicity. Goal and Challenge scores only move on full completion. This is the cheapest possible signal to compute, but it's also the least responsive — a user working steadily toward a goal gets zero score feedback for that effort until the last moment.

  4. Trigger-scoped recomputation is efficient but breaks the "any progress should move the score" intuition. It's correct behavior for each individual component, but it means the felt responsiveness of the score depends on which trigger fired, not just on what the user did.

  5. If leadership's original continuous-movement intent is a hard requirement, the fix is architectural, not cosmetic. Moving from discrete buckets to a continuous formula (e.g. scoring budget adherence as a smooth function of ratio rather than 6 fixed steps) and adding partial-progress credit for goals/challenges would address the "doesn't move" complaint directly — but it's a rework of the scoring model itself, not a tuning tweak to the current one.

  6. A defensive clamp that turns out to be mathematically redundant is still worth keeping. It costs nothing and protects against a future component-range change silently producing an invalid score — but it shouldn't be mistaken for a meaningful part of the current score's behavior.