feat(goals/demo): seed full state-coverage matrix + sample pledges

User asked for demo seed variety so every goal state surfaces on at
least one card. Previous seed only spanned 4 AASM states; the
computed status (:reached / :on_track / :behind / :no_target_date)
and the edge-state copy paths (past-due target_date, open pledge
banner, "Last pledge matched") were absent.

New seed coverage matrix:

  AASM states (column):
    active     → Vacation in Italy, Wedding fund, Emergency fund,
                 House downpayment, Coffee gear, Tax prep buffer
    paused     → Sabbatical
    completed  → Paid-off car
    archived   → Old laptop fund

  Computed status (active goals):
    :behind         → Vacation in Italy, House downpayment, Tax prep buffer
    :on_track-ish   → Wedding fund (12-month timeline + small target)
    :no_target_date → Emergency fund
    :reached        → Coffee gear (target 150 below any plausible
                      account balance — progress hits 100% live)

  Edge surfaces:
    Past-due active     → Tax prep buffer (target_date 2.months.ago,
                          exercises "was due" header copy and the
                          months_remaining = 0 branch in
                          monthly_target_amount)
    Open pledge banner  → Vacation in Italy + House downpayment each
                          ship a single open pledge. The show-page
                          banner renders; the index pending-pledges
                          callout renders because @any_pending_pledge
                          flips true.
    Matched pledge      → Wedding fund: after the main seed loop,
                          find_by(name: "Wedding fund") + locate the
                          most recent non-claimed primary-account
                          inflow Transaction (>= 30 days, amount < 0
                          per Sure's sign convention), create a
                          matched-status pledge against it, stamp
                          the Transaction's extra->goal->pledge_id
                          per the partial-unique-index invariant.
                          The show-header then renders "Last pledge
                          matched N days ago" via
                          Goal#last_matched_pledge_at.

Implementation notes:

- Pledges spec embeds inside each goal_spec as an optional `pledges:`
  array. The loop creates them after goal.save! using the goal's
  linked_accounts as the default account; the GoalPledge#
  account_must_be_linked_to_goal validation passes because every
  spec's account is one of the goal's linked accounts.

- The matched-pledge seed is split into a dedicated helper
  (`seed_matched_pledge_demo_for_wedding!`) because it depends on
  Transactions seeded earlier in the demo flow. Both no-Wedding-
  goal and no-recent-inflow guards bail cleanly so older demo
  variants still work.

- All seed targets are intentional. Goal#status reads the live
  linked-account balance + 90-day inflow at render time, so the
  demo statuses adapt to whatever the rest of the demo seeded.
  The targets are sized so the *intended* status is the most
  likely one for typical demo data.

Local DB unaffected: this is the demo-family generator only, run
via `Demo::Generator.new.generate_default_data!` against a fresh
family.
This commit is contained in:
Guillem Arias
2026-05-14 22:39:23 +02:00
parent 82f3d2e0fb
commit d32992769c

View File

@@ -1286,36 +1286,64 @@ class Demo::Generator
eligible = depository_accounts.select { |a| a.currency == currency }
primary = eligible.first
# V2 goals derive balance + pace from the linked depository accounts
# directly; the demo's contribution arrays were V1 ledger seed data
# and have nothing to consume them now. Account-level transaction
# seeding (paychecks, etc.) elsewhere in this generator already
# populates the goal pace/balance.
# Demo coverage matrix. Picks targets + target_dates so every visible
# goal surface fires on at least one card:
# AASM states: active, paused, completed, archived
# Computed status (on active goals):
# :reached, :on_track, :behind, :no_target_date
# Edge surfaces: past-due target_date ("was due"), open pledge
# banner, matched pledge ("last pledge matched")
goals = [
# active · behind (short timeline + non-trivial target)
{
name: "Vacation in Italy",
target: 5_000,
target_date: 4.months.from_now.to_date,
accounts: eligible.first(2)
accounts: eligible.first(2),
pledges: [
{ account: primary, amount: 250, kind: "transfer", status: "open", expires_at: 5.days.from_now }
]
},
# active · on_track-ish (small target, year out — required rate fits any reasonable pace)
{
name: "Wedding fund",
target: 2_400,
target_date: 6.months.from_now.to_date,
target_date: 12.months.from_now.to_date,
accounts: eligible.first(2)
},
# active · no_target_date — exercises the open-ended branch
{
name: "Emergency fund",
target: 10_000,
target_date: nil,
accounts: [ primary ]
},
# active · behind (large multi-year target — no realistic pace covers $50k/24mo)
{
name: "House downpayment",
target: 50_000,
target_date: 24.months.from_now.to_date,
accounts: eligible.first(2)
accounts: eligible.first(2),
pledges: [
{ account: primary, amount: 2_000, kind: "transfer", status: "open", expires_at: 4.days.from_now }
]
},
# active · reached (target intentionally below any plausible primary balance)
{
name: "Coffee gear",
target: 150,
target_date: 8.months.from_now.to_date,
accounts: [ primary ]
},
# active · past-due (target_date in the past — exercises "was due" header copy
# and the months_remaining = 0 branch in monthly_target_amount)
{
name: "Tax prep buffer",
target: 1_200,
target_date: 2.months.ago.to_date,
accounts: [ primary ]
},
# AASM paused
{
name: "Sabbatical",
target: 15_000,
@@ -1323,6 +1351,7 @@ class Demo::Generator
state: "paused",
accounts: [ primary ]
},
# AASM archived
{
name: "Old laptop fund",
target: 1_500,
@@ -1330,6 +1359,7 @@ class Demo::Generator
state: "archived",
accounts: [ primary ]
},
# AASM completed
{
name: "Paid-off car",
target: 8_000,
@@ -1350,8 +1380,56 @@ class Demo::Generator
)
goal_spec[:accounts].uniq.each { |a| goal.goal_accounts.build(account: a) }
goal.save!
Array(goal_spec[:pledges]).each do |pledge_spec|
goal.goal_pledges.create!(
account: pledge_spec[:account] || goal.linked_accounts.first,
amount: pledge_spec[:amount],
currency: currency,
kind: pledge_spec[:kind] || "transfer",
status: pledge_spec[:status] || "open",
expires_at: pledge_spec[:expires_at] || 7.days.from_now
)
end
end
seed_matched_pledge_demo_for_wedding!(family, currency, primary)
puts " ✅ Seeded #{goals.size} goals"
end
# Bind one matched pledge on the Wedding fund to a real recent demo
# inflow Transaction. Surfaces the "Last pledge matched N days ago"
# header copy + exercises the partial-unique index on
# transactions.extra->'goal'->>'pledge_id'.
def seed_matched_pledge_demo_for_wedding!(family, currency, primary)
wedding = family.goals.find_by(name: "Wedding fund")
return unless wedding && primary
recent_inflow_entry = Entry
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where(account_id: primary.id, excluded: false)
.where("entries.amount < 0")
.where("entries.date >= ?", 30.days.ago.to_date)
.where("(transactions.extra -> 'goal' ->> 'pledge_id') IS NULL")
.order("entries.date DESC")
.first
return unless recent_inflow_entry
pledge = wedding.goal_pledges.create!(
account: primary,
amount: recent_inflow_entry.amount.to_d.abs,
currency: currency,
kind: "transfer",
status: "matched",
matched_transaction_id: recent_inflow_entry.entryable_id,
expires_at: 7.days.ago
)
txn = recent_inflow_entry.entryable
new_extra = (txn.extra || {}).deep_dup
new_extra["goal"] = (new_extra["goal"] || {}).merge("pledge_id" => pledge.id)
txn.update!(extra: new_extra)
end
end