Ask A Question

Notifications

You’re not receiving notifications from this thread.

Guidance how to structure models for site credits / user balances

Dolf asked in Rails
Dear GoRails-Community,

Super happy to finally have joined! Thanks Chris for building the site and community. Really love that it allows to discuss approaches which always gets downvoted on SO :)

I'm building a small SEO tool as a side project (live in alpha for free at the moment, bearing some API cost). Instead of charging monthly, I plan to give users the option to purchase credits to then be used for multiple actions on the site.

Here is how I sketched it out on "paper" with inheritance after googling a lot:

class Transaction < ApplicationRecord
  belongs_to :user
  # sub_types: buy_credits, spend_credits, add_free_credits
  # columns: credit_change
end

class Add_free_credits < Transaction
  has_one :admin_user  # if added manually by the admin as free credit; nil if added by the system for signup
  # columns: credit_change (inherited from transaction, values positive), type ("sign_up_bonus", "goodwill")
end

class Purchase_credits < Transaction
  has_one :payment  # to be added later
  # columns: credit_change (inherited from transaction, values positive), currency, price, amount
end

class Spend_credits < Transaction
  belongs_to :site_action  # the action the user is charged for (dummy name)
  # columns: credit_change (inherited from transaction, values negative)
end


class User
  ...
  def credits_balance # or self.credits_balance ?? :)
    @user.transactions.sum(:amount)
  end
  # Thanks to Casey Provost, https://gorails.com/forum/how-do-i-create-a-virtual-balance-model-in-rails
end

So, I have 2 questions I have with this:

1) Should I really go the "inherited" route? Transactions feel similar enough, but at the same time also different enough to justify it. The alternative (transaction model only, with a column "type") feels messy

2) The inherited model names sound more like actions "add_free_credits", ... This worries me a bit. Should I either change the names to, e.g., "Purchase_transaction" (or credit/debit) and then add the actions, or these are rather functions inside one model?

3) Quick naming question, would you rather use "Purchases" of users or site "Sales"?

Any feedback is highly appreciated, thanks so much :)
Reply
1. You absolutely can use STI for credit/debit actions to an account, because it makes aggregating them for virtual balance at database level a breeze. Plutus does this, for example. However in this case I think even that is unnecessary.

2. In general, persisted entity names should be a noun, not a verb. This is true even when the persisted entity is modelling a process or action, in which case use the noun that names the action, not verbs that describe it.

3. A sale is a commercial act that is documented by an invoice. The entity you're recording here, though, is an increment or decrement in credits. Those are two separate concepts and should be modelled accordingly. Your addition or subtraction of credits should be linked to the reason, not conflated with it. In other words you don't need the "Add_free_credits" model or indeed any subclass.

Note also: underscores in class names are going to extremely confusing for developer and framework alike. Don't do that. Rails especially is going to get very, very confused about them.

The reason for each increase or decrease should be a separate and probably polymorphic belongs_to, linking to a model such as "Sale" or "Freebie" or "Usage" that explains the movement separately from the the record of the movement itself. That way you don't need different types of movement.

This is single-entry book-keeping in a nutshell, by the way. So let's call each "transaction" an AccountEntry.

I also believe the balance computation has no business being in your user class. That should be in an Account object.

I haven't tested this even for syntax errors let alone function but I'd be looking for something like:

class AccountEntry < ApplicationRecord
  belongs_to :user
  belongs_to :reason, optional: true, polymorphic: true

  validates_numericality_of :change
end

Account = Struct.new(:user) do
  def balance
    user.account_entries.sum(:change)
  end
end

class User < ApplicationRecord
  has_many :account_entries
  has_many :sales

  def account
    Account.new(self)
  end
end

class Sale < ApplicationRecord
  belongs_to :user
  has_one :account_entry, required: true, as: :reason, dependent: :nullify
  before_create :build_associations

  validates_numericality_of :price, :credits, greater_than: 0

  private
    def build_associations
      account_entry || build_account_entry(user: user, change: credits)
    end
end


The idea being that then you can write
current_user.sales.create!(price: 2000, credits: 20)
in the SalesController, and 
Balance: <%= current_user.account.balance %>
in a view and so on. If you needed other methods later e.g. query methods on the balance, those go in the Account class, and other classes representing a Freebie, an Usage or even a Refund would be patterned after Sale and are possibly STI models descending from a Transaction class.

Reply
Thank you so much inopinatus for taking the time, this is super helpful!

Interesting approach to use different "reason" models without inheritance but via
as :reasons
Makes a lot of sense, wouldn't have come up with that.

Also, will read up on Struct.new :)

Thanks again!
Reply
Join the discussion
Create an account Log in

Want to stay up-to-date with Ruby on Rails?

Join 87,110+ developers who get early access to new tutorials, screencasts, articles, and more.

    We care about the protection of your data. Read our Privacy Policy.