Guidance how to structure models for site credits / user balances
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:
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 :)
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:
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.