Skip to content

Commit

Permalink
Rework ledgers view (#687)
Browse files Browse the repository at this point in the history
* Remove unused dashboard ledger line code

* Rework ledger lines with recent lines

We want to show recent lines first when there are ledger transactions.
And we want to show the first ledger when there are no transactions.

- Rework ledgers view to expose more line details to the API
- Remove unnecessary items in `Suma::Member::Dashboard` and add necessary to `Suma::Payment::LedgersView` like `lifetime_savings`
- Display recent lines when there are transactions available.

* Add cache support to useAsyncFetch

Prevents multiple API calls when switching pages.

---------

Co-authored-by: Rob Galanakis <[email protected]>
  • Loading branch information
DeeTheDev and rgalanakis authored Sep 10, 2024
1 parent 7d6a065 commit 65912d9
Show file tree
Hide file tree
Showing 13 changed files with 236 additions and 294 deletions.
25 changes: 4 additions & 21 deletions lib/suma/api/ledgers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,8 @@ class Suma::API::Ledgers < Suma::API::V1
ledgers = (me.payment_account&.ledgers || []).select do |led|
led.any_transactions? || led.vendor_service_categories.first&.slug === "cash"
end
lv = Suma::Payment::LedgersView.new(ledgers)
first_page = []
page_count = 0
if (first_ledger = lv.ledgers.first)
first_page = first_ledger.combined_book_transactions_dataset
first_page = paginate(first_page, {page: 1, per_page: Suma::Service::SHORT_PAGE_SIZE})
page_count = first_page.page_count
first_page = first_page.all.map { |led| led.directed(first_ledger) }
end
present(
lv,
with: LedgersViewEntity,
first_ledger_lines_first_page: first_page,
first_ledger_page_count: page_count,
)
lv = Suma::Payment::LedgersView.new(ledgers, member: me)
present(lv, with: LedgersViewEntity)
end

route_param :id, type: Integer do
Expand Down Expand Up @@ -57,12 +44,8 @@ class LedgerLinesEntity < Suma::Service::Collection::BaseEntity
class LedgersViewEntity < BaseEntity
include Suma::API::Entities
expose :total_balance, with: MoneyEntity
expose :lifetime_savings, with: MoneyEntity
expose :ledgers, with: LedgerEntity
expose :first_ledger_lines_first_page, with: LedgerLineEntity do |_, opts|
opts.fetch(:first_ledger_lines_first_page)
end
expose :first_ledger_page_count do |_, opts|
opts.fetch(:first_ledger_page_count)
end
expose :recent_lines, with: LedgerLineEntity
end
end
13 changes: 0 additions & 13 deletions lib/suma/api/me.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,6 @@ class Suma::API::Me < Suma::API::V1
end
end

class AvailableOfferingEntity < BaseEntity
include Suma::API::Entities
expose :id
expose_translated :description
expose :period_end, as: :closes_at
expose :image, with: Suma::API::Entities::ImageEntity, &self.delegate_to(:images?, :first)
end

class DashboardLedgerLineEntity < BaseEntity
include Suma::API::Entities
include LedgerLineAmountMixin
Expand All @@ -106,11 +98,6 @@ class VendibleGroupingEntity < BaseEntity

class MemberDashboardEntity < BaseEntity
include Suma::API::Entities
expose :payment_account_balance, with: MoneyEntity
expose :lifetime_savings, with: MoneyEntity
expose :ledger_lines, with: DashboardLedgerLineEntity
expose :next_offerings, as: :offerings, with: AvailableOfferingEntity
expose :mobility_available?, as: :mobility_vehicles_available
expose :vendible_groupings, with: VendibleGroupingEntity
end
end
28 changes: 0 additions & 28 deletions lib/suma/member/dashboard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,6 @@ def initialize(member, at:)
@at = at
end

def payment_account_balance
pa = @member.payment_account
return Money.new(0) if pa.nil?
return pa.total_balance
end

def lifetime_savings
return @member.charges.sum(Money.new(0), &:discount_amount)
end

def ledger_lines
if @ledger_lines.nil?
pa = @member.payment_account
@ledger_lines = pa.nil? ? [] : Suma::Payment::LedgersView.new(pa.ledgers).recent_lines
end
return @ledger_lines
end

def next_offerings(limit: 2) = self.offerings.take(limit)

def offerings
return @offerings ||= Suma::Commerce::Offering.
available_at(@at).
Expand All @@ -47,14 +27,6 @@ def vendor_services
return @vendor_services ||= self.vendor_services_dataset.order { upper(period) }.all
end

def mobility_available?
if @mobility_available.nil?
vehicles = Suma::Mobility::Vehicle.where(vendor_service: self.vendor_services_dataset)
@mobility_available = !vehicles.empty?
end
return @mobility_available
end

def vendible_groupings
return @vendible_groupings ||= Suma::Vendible.groupings(self.offerings + self.vendor_services)
end
Expand Down
21 changes: 17 additions & 4 deletions lib/suma/payment/ledgers_view.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ class Suma::Payment::LedgersView
attr_reader :ledgers
attr_accessor :minimum_recent_lines

def initialize(ledgers, now: Time.now)
@ledgers = ledgers || []
def initialize(ledgers=[], member:, now: Time.now)
@ledgers = ledgers
@member = member
@now = now
@minimum_recent_lines = 10
end
Expand All @@ -14,8 +15,12 @@ def total_balance
return self.ledgers.sum(Money.new(0), &:balance)
end

def lifetime_savings
return @member.charges.sum(Money.new(0), &:discount_amount)
end

class RecentLine < Suma::TypedStruct
attr_accessor :amount, :apply_at, :memo
attr_accessor :id, :amount, :apply_at, :memo, :opaque_id, :usage_details

def directed? = true
end
Expand Down Expand Up @@ -57,7 +62,15 @@ def recent_lines
if (recent_line = merged[key])
recent_line.amount += bx.amount
else
merged[key] = RecentLine.new(amount: bx.amount, apply_at: bx.apply_at, memo: bx.memo)
merged[key] =
RecentLine.new(
id: bx.id,
amount: bx.amount,
apply_at: bx.apply_at,
memo: bx.memo,
opaque_id: bx.opaque_id,
usage_details: bx.usage_details,
)
end
end
# Sort lines by recency, then within the same instant:
Expand Down
35 changes: 19 additions & 16 deletions spec/suma/api/ledgers_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
expect(last_response_json_body).to include(
ledgers: [],
total_balance: cost("$0"),
first_ledger_page_count: 0,
first_ledger_lines_first_page: [],
lifetime_savings: cost("$0"),
recent_lines: [],
)
end

Expand All @@ -35,34 +35,37 @@
expect(last_response).to have_status(200)
expect(last_response_json_body).to include(
ledgers: contain_exactly(include(id: led.id, name: "Cash")),
first_ledger_page_count: 1,
first_ledger_lines_first_page: [],
recent_lines: [],
)
end

it "returns an overview of all ledgers and items from the first ledger" do
it "returns an overview of all ledgers, recent transactions and total balances" do
led1 = Suma::Fixtures.ledger.member(member).create(name: "A")
led2 = Suma::Fixtures.ledger.member(member).create(name: "B")
recent_xaction = bookfac.from(led1).create(apply_at: 20.days.ago, amount_cents: 100)
old_xaction = bookfac.to(led1).create(apply_at: 80.days.ago, amount_cents: 400)
# Make led2 non-empty
bookfac.from(led2).create(amount_cents: 200)
bookfac.to(led2).create(amount_cents: 200)
charge = Suma::Fixtures.charge(member:).create(undiscounted_subtotal: money("$30"))
led1_recent_xaction = bookfac.from(led1).create(apply_at: 20.days.ago, amount_cents: 100)
led1_old_xaction = bookfac.to(led1).create(apply_at: 80.days.ago, amount_cents: 400)
charge.add_book_transaction(led1_recent_xaction)
charge.add_book_transaction(led1_old_xaction)
led2_recent_xaction = bookfac.from(led2).create(apply_at: 5.days.ago, amount_cents: 200)
led2_old_xaction = bookfac.to(led2).create(apply_at: 10.days.ago, amount_cents: 500)

get "/v1/ledgers/overview"

expect(last_response).to have_status(200)
expect(last_response_json_body).to include(
ledgers: contain_exactly(
include(id: led1.id, name: "A", balance: cost("$3")),
include(id: led2.id, name: "B", balance: cost("$0")),
include(id: led2.id, name: "B", balance: cost("$3")),
),
total_balance: cost("$3"),
first_ledger_page_count: 1,
first_ledger_lines_first_page: match(
total_balance: cost("$6"),
lifetime_savings: cost("$25"),
recent_lines: match(
[
include(amount: cost("-$1"), at: match_time(recent_xaction.apply_at)),
include(amount: cost("$4"), at: match_time(old_xaction.apply_at)),
include(amount: cost("-$2"), at: match_time(led2_recent_xaction.apply_at)),
include(amount: cost("$5"), at: match_time(led2_old_xaction.apply_at)),
include(amount: cost("-$1"), at: match_time(led1_recent_xaction.apply_at)),
include(amount: cost("$4"), at: match_time(led1_old_xaction.apply_at)),
],
),
)
Expand Down
5 changes: 0 additions & 5 deletions spec/suma/api/me_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -126,18 +126,13 @@

describe "GET /v1/me/dashboard" do
it "returns the dashboard" do
cash_ledger = Suma::Fixtures.ledger.member(member).category(:cash).create
Suma::Fixtures.book_transaction.to(cash_ledger).create(amount: money("$27"))
Suma::Fixtures.vendible_group.with_offering.create

get "/v1/me/dashboard"

expect(last_response).to have_status(200)
expect(last_response).to have_json_body.
that_includes(
payment_account_balance: cost("$27"),
lifetime_savings: cost("$0"),
ledger_lines: have_length(1),
vendible_groupings: have_length(1),
)
end
Expand Down
69 changes: 4 additions & 65 deletions spec/suma/member/dashboard_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,78 +8,17 @@

it "can represent a blank/empty member" do
d = described_class.new(member, at: now)
expect(d).to have_attributes(
payment_account_balance: cost("$0"),
lifetime_savings: cost("$0"),
ledger_lines: be_empty,
)
end

it "can represent a member with ledgers and transactions" do
cash_ledger = Suma::Fixtures.ledger.member(member).category(:cash).create
grocery_ledger = Suma::Fixtures.ledger.member(member).category(:food).create
# Add charges, one with transactions
charge1 = Suma::Fixtures.charge(member:).create(undiscounted_subtotal: money("$30"))
charge1.add_book_transaction(
Suma::Fixtures.book_transaction.from(cash_ledger).create(amount: money("$20"), apply_at: 20.days.ago),
)
charge1.add_book_transaction(
Suma::Fixtures.book_transaction.from(grocery_ledger).create(amount: money("$5"), apply_at: 21.days.ago),
)
charge2 = Suma::Fixtures.charge(member:).create(undiscounted_subtotal: money("$4.31"))
# Add book transactions for funding events
Suma::Fixtures.book_transaction.to(cash_ledger).create(amount: money("$27"))
d = described_class.new(member, at: now)
expect(d).to have_attributes(
payment_account_balance: cost("$2"),
lifetime_savings: cost("$9.31"),
ledger_lines: match(
[
have_attributes(amount: cost("$27")),
have_attributes(amount: cost("-$20")),
have_attributes(amount: cost("-$5")),
],
),
offerings: [],
mobility_available?: false,
)
end

it "includes the two offerings closing next" do
ec = Suma::Fixtures.eligibility_constraint.create
member.add_verified_eligibility_constraint(ec)
member.update(onboarding_verified_at: 2.minutes.ago)
ofac = Suma::Fixtures.offering.with_constraints(ec)
ofac.closed.description("closed").create
middle = ofac.description("middle ahead").create(period: 1.day.ago..10.days.from_now)
closest = ofac.description("closest").create(period: 1.day.ago..7.days.from_now)
ofac.description("furthest ahead").create(period: 1.day.ago..11.days.from_now)

d = described_class.new(member, at: now)
expect(d).to have_attributes(next_offerings: have_same_ids_as(closest, middle).ordered)
end

it "includes whether vehicles are available" do
ec = Suma::Fixtures.eligibility_constraint.create
member.add_verified_eligibility_constraint(ec)
member.update(onboarding_verified_at: 2.minutes.ago)

vendor_service = Suma::Fixtures.vendor_service.mobility.with_constraints(ec).create
expect(described_class.new(member, at: now)).to_not be_mobility_available

Suma::Fixtures.mobility_vehicle.escooter.create(vendor_service:)
expect(described_class.new(member, at: now)).to be_mobility_available

vendor_service.update(period_end: 2.minutes.ago)
expect(described_class.new(member, at: now)).to_not be_mobility_available
expect(d).to have_attributes(vendor_services: [], offerings: [])
end

it "includes vendible groupings for eligible vendor services and offerings" do
vs = Suma::Fixtures.vendor_service.mobility.create
off = Suma::Fixtures.offering.create
vg1 = Suma::Fixtures.vendible_group.with_(vs).create
vg2 = Suma::Fixtures.vendible_group.with_(vs).create
vg2 = Suma::Fixtures.vendible_group.with_(off).create
expect(described_class.new(member, at: now)).to have_attributes(
offerings: have_same_ids_as(off),
vendor_services: have_same_ids_as(vs),
vendible_groupings: contain_exactly(
have_attributes(group: vg1),
have_attributes(group: vg2),
Expand Down
28 changes: 20 additions & 8 deletions spec/suma/payment/ledgers_view_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,22 @@
require "suma/payment/ledgers_view"

RSpec.describe Suma::Payment::LedgersView, :db do
let(:account) { Suma::Fixtures.payment_account.create }
let(:member) { account.member }

before(:each) do
# a language must be set since we call the recent_lines method
# that returns book transactions containing usage_details, which returns
# translated memo string that checks for SequelTranslatedText.language!
SequelTranslatedText.language = :en
end

after(:each) do
SequelTranslatedText.language = nil
end

it "can represent empty ledgers" do
d = described_class.new([])
d = described_class.new(member: Suma::Fixtures.member.create)
expect(d).to have_attributes(
total_balance: cost("$0"),
recent_lines: [],
Expand All @@ -13,14 +27,13 @@
end

it "can represent ledgers and transactions" do
account = Suma::Fixtures.payment_account.create
cash_ledger = Suma::Fixtures.ledger(account:).category(:cash).create(name: "Dolla")
grocery_ledger = Suma::Fixtures.ledger(account:).category(:food).create(name: "Grub")
Suma::Fixtures.book_transaction.from(cash_ledger).create(amount: money("$20"), apply_at: 20.days.ago)
Suma::Fixtures.book_transaction.from(grocery_ledger).create(amount: money("$5"), apply_at: 21.days.ago)
Suma::Fixtures.book_transaction.from(grocery_ledger).create(amount: money("$1"), apply_at: 80.days.ago)
Suma::Fixtures.book_transaction.to(cash_ledger).create(amount: money("$27"))
d = described_class.new(account.ledgers)
d = described_class.new(account.ledgers, member:)
d.minimum_recent_lines = 3
expect(d).to have_attributes(
total_balance: cost("$1"),
Expand All @@ -36,12 +49,11 @@
end

describe "recent_lines" do
let(:account) { Suma::Fixtures.payment_account.create }
let!(:cash_ledger) { Suma::Fixtures.ledger(account:).category(:cash).create(name: "Dolla") }
let!(:grocery_ledger) { Suma::Fixtures.ledger(account:).category(:food).create(name: "Grub") }

it "includes the last 60 days of transactions" do
d = described_class.new(account.ledgers)
d = described_class.new(account.ledgers, member:)
d.minimum_recent_lines = 2

Suma::Fixtures.book_transaction.to(cash_ledger).create(amount: money("$1"))
Expand All @@ -61,7 +73,7 @@
end

it "always includes a minimum number of transactions if not enough are recent" do
d = described_class.new(account.ledgers)
d = described_class.new(account.ledgers, member:)
d.minimum_recent_lines = 3

Suma::Fixtures.book_transaction.to(cash_ledger).create(amount: money("$1"))
Expand Down Expand Up @@ -102,7 +114,7 @@
fac.create(amount: money("$5"), apply_at: tm20, memo: m3)
fac.create(amount: money("$5"), apply_at: tm20, memo: m1)
fac.create(amount: money("$5"), apply_at: tm20, memo: m2)
expect(described_class.new(account.ledgers).recent_lines).to match(
expect(described_class.new(account.ledgers, member:).recent_lines).to match(
[
have_attributes(amount: cost("$1")),
have_attributes(amount: cost("$100")),
Expand Down Expand Up @@ -134,7 +146,7 @@
fac.create(amount: money("$1000"), apply_at: t1, memo: m2)
fac.create(amount: money("$10000"), apply_at: t2, memo: m2)
fac.create(amount: money("$100000"), apply_at: t2, memo: m2)
expect(described_class.new(account.ledgers).recent_lines).to contain_exactly(
expect(described_class.new(account.ledgers, member:).recent_lines).to contain_exactly(
have_attributes(amount: cost("$11"), apply_at: match_time(t1), memo: be === m1),
have_attributes(amount: cost("$100"), apply_at: match_time(t2), memo: be === m1),
have_attributes(amount: cost("$1000"), apply_at: match_time(t1), memo: be === m2),
Expand Down
Loading

0 comments on commit 65912d9

Please sign in to comment.