mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 07:49:01 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user