accountability 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +60 -17
  3. data/app/assets/config/accountability_manifest.js +1 -0
  4. data/app/assets/stylesheets/{acts_as_billable → accountability}/application.css +1 -1
  5. data/app/controllers/accountability/accounts_controller.rb +19 -0
  6. data/app/controllers/accountability/application_controller.rb +45 -0
  7. data/app/controllers/accountability/order_groups_controller.rb +65 -0
  8. data/app/controllers/accountability/products_controller.rb +56 -0
  9. data/app/helpers/accountability/application_helper.rb +19 -0
  10. data/app/models/accountability/account.rb +28 -0
  11. data/app/models/accountability/account/transactions.rb +43 -0
  12. data/app/models/accountability/application_record.rb +3 -0
  13. data/app/models/accountability/coupon.rb +39 -0
  14. data/app/models/accountability/credit.rb +46 -0
  15. data/app/models/accountability/debit.rb +14 -0
  16. data/app/models/accountability/deduction.rb +17 -0
  17. data/app/models/accountability/discount.rb +36 -0
  18. data/app/models/accountability/offerable.rb +76 -0
  19. data/app/models/accountability/order_group.rb +52 -0
  20. data/app/models/accountability/order_item.rb +88 -0
  21. data/app/models/accountability/payment.rb +8 -0
  22. data/app/models/accountability/product.rb +53 -0
  23. data/app/views/accountability/accounts/index.html.haml +13 -0
  24. data/app/views/accountability/accounts/show.html.haml +12 -0
  25. data/app/views/accountability/order_groups/new.html.haml +1 -0
  26. data/app/views/accountability/order_groups/show.html.haml +16 -0
  27. data/app/views/accountability/products/edit.html.erb +1 -0
  28. data/app/views/accountability/products/index.html.haml +39 -0
  29. data/app/views/accountability/products/new.html.erb +40 -0
  30. data/app/views/accountability/products/show.html.erb +1 -0
  31. data/app/views/layouts/acts_as_billable/application.html.erb +2 -2
  32. data/config/routes.rb +1 -3
  33. data/db/migrate/20190814000455_create_accountability_tables.rb +117 -0
  34. data/lib/accountability.rb +10 -0
  35. data/lib/accountability/cartographer.rb +28 -0
  36. data/lib/accountability/configuration.rb +16 -0
  37. data/lib/accountability/engine.rb +9 -0
  38. data/lib/accountability/extensions.rb +15 -0
  39. data/lib/accountability/extensions/acts_as_billable.rb +14 -0
  40. data/lib/accountability/extensions/acts_as_offerable.rb +36 -0
  41. data/lib/accountability/version.rb +3 -0
  42. metadata +40 -14
  43. data/app/assets/config/acts_as_billable_manifest.js +0 -1
  44. data/app/controllers/acts_as_billable/application_controller.rb +0 -3
  45. data/app/controllers/acts_as_billable/pages_controller.rb +0 -5
  46. data/app/helpers/acts_as_billable/application_helper.rb +0 -2
  47. data/app/models/acts_as_billable/application_record.rb +0 -3
  48. data/app/models/acts_as_billable/product.rb +0 -2
  49. data/app/views/acts_as_billable/pages/dashboard.html.erb +0 -1
  50. data/db/migrate/20190814000455_create_acts_as_billable_tables.rb +0 -51
  51. data/lib/acts_as_billable.rb +0 -5
  52. data/lib/acts_as_billable/engine.rb +0 -3
  53. data/lib/acts_as_billable/version.rb +0 -3
@@ -0,0 +1,14 @@
1
+ class Accountability::Debit < ApplicationRecord
2
+ before_validation :set_amount
3
+
4
+ belongs_to :account
5
+ belongs_to :payment, optional: true
6
+
7
+ private
8
+
9
+ def set_amount
10
+ return unless amount.zero?
11
+
12
+ self.amount = payment.amount
13
+ end
14
+ end
@@ -0,0 +1,17 @@
1
+ class Accountability::Deduction < ApplicationRecord
2
+ belongs_to :discount
3
+ belongs_to :credit
4
+ has_one :coupon, through: :discount, inverse_of: :deductions
5
+
6
+ after_initialize :set_amount
7
+
8
+ delegate :name, to: :coupon, prefix: true
9
+
10
+ private
11
+
12
+ def set_amount
13
+ return if amount.positive?
14
+
15
+ self.amount = discount.expected_savings
16
+ end
17
+ end
@@ -0,0 +1,36 @@
1
+ class Accountability::Discount < ApplicationRecord
2
+ belongs_to :coupon
3
+ belongs_to :order_item
4
+ has_many :deductions, dependent: :nullify
5
+
6
+ delegate :amount, to: :coupon, prefix: true
7
+
8
+ def apply(credit)
9
+ return unless usable?
10
+
11
+ credit.deductions.new(discount: self)
12
+ end
13
+
14
+ def usages
15
+ deductions.count
16
+ end
17
+
18
+ def usable?
19
+ return false if used_up?
20
+
21
+ coupon.usable?
22
+ end
23
+
24
+ def expected_savings
25
+ percent_off = coupon_amount / 100.00
26
+ order_item.default_price * percent_off
27
+ end
28
+
29
+ private
30
+
31
+ def used_up?
32
+ return false if coupon.usage_cap.blank?
33
+
34
+ usages >= coupon.usage_cap
35
+ end
36
+ end
@@ -0,0 +1,76 @@
1
+ # TODO: Reconsider using names as keys, maybe just arrays?
2
+
3
+ class Accountability::Offerable
4
+ cattr_accessor :collection, default: {}
5
+ attr_accessor :tenant, :category, :class_name, :trait, :scopes, :properties, :callbacks
6
+
7
+ def initialize(category, tenant: :default, trait: nil, class_name:)
8
+ @category = category
9
+ @tenant = tenant
10
+ @class_name = class_name
11
+ @trait = trait
12
+ @scopes = {}
13
+ @properties = {}
14
+ @callbacks = {}
15
+ end
16
+
17
+ def self.add(category, tenant: :default, trait: nil, class_name:)
18
+ category = category.to_s.underscore.downcase.to_sym
19
+ offerable = new(category, tenant: tenant, trait: trait, class_name: class_name)
20
+ collection[category] = offerable
21
+
22
+ offerable
23
+ end
24
+
25
+ def add_scope(name, title: name, options: :auto)
26
+ scopes[name] = { title: title.to_s, options: options, category: category }
27
+
28
+ self
29
+ end
30
+
31
+ def add_property(name, title: name, position: nil)
32
+ position = properties.size.next if position.nil?
33
+ properties[name] = { title: title.to_s, position: position }
34
+ self
35
+ end
36
+
37
+ def add_properties(*names)
38
+ names.each do |name|
39
+ add_property(name)
40
+ end
41
+
42
+ self
43
+ end
44
+
45
+ def add_callback(method_name, **options)
46
+ tense, event = options.slice(:before, :after).flatten
47
+ trigger = "#{tense}_#{event}".to_sym
48
+
49
+ params = if options[:with_options].present?
50
+ options[:with_options].to_h { |option| [option, option] }
51
+ else
52
+ [*options[:with]]
53
+ end
54
+
55
+ callbacks[trigger] ||= []
56
+ callbacks[trigger] << { method_name: method_name, params: params }
57
+
58
+ self
59
+ end
60
+
61
+ def trait?
62
+ trait.present?
63
+ end
64
+
65
+ private
66
+
67
+ # TODO: Abstract this out
68
+ def current_tenant
69
+ :default
70
+ end
71
+ end
72
+
73
+ # Load all models so offerable items are registered
74
+ Dir[Rails.root + 'app/models/*.rb'].each do |path|
75
+ require_dependency path
76
+ end
@@ -0,0 +1,52 @@
1
+ # OrderGroups are like shopping carts - they group together purchases (ordered items) that were made together.
2
+ # We wanted to name this 'Order', but for obvious reasons that wouldn't work out
3
+ # In the future we can use this for tracking referrals and contracts.
4
+
5
+ module Accountability
6
+ class OrderGroup < ApplicationRecord
7
+ belongs_to :account, optional: true
8
+
9
+ has_many :order_items, dependent: :destroy
10
+
11
+ enum status: %i[pending complete abandoned]
12
+
13
+ validates :account, presence: true, if: :complete?
14
+
15
+ def checkout!
16
+ trigger_callback :before_checkout
17
+
18
+ transaction do
19
+ complete!
20
+ accrue_credits!
21
+
22
+ trigger_callback :after_checkout
23
+ end
24
+ end
25
+
26
+ def accrue_credits!
27
+ return unless complete?
28
+
29
+ order_items.each(&:accrue_credit!)
30
+ end
31
+
32
+ def add_item!(product)
33
+ order_items.create! product: product
34
+ end
35
+
36
+ def unassigned?
37
+ account.nil?
38
+ end
39
+
40
+ def assign_account!(account)
41
+ update! account: account
42
+ end
43
+
44
+ private
45
+
46
+ def trigger_callback(trigger)
47
+ order_items.each do |item|
48
+ item.trigger_callback trigger
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,88 @@
1
+ # An OrderItem represents a Product that has been (or is being) purchased
2
+ # They are stored in an OrderGroup, which acts like a shopping cart
3
+
4
+ class Accountability::OrderItem < ApplicationRecord
5
+ belongs_to :product
6
+ belongs_to :order_group
7
+ has_one :account, through: :order_group
8
+ has_many :credits, dependent: :destroy
9
+ has_many :discounts, dependent: :destroy
10
+
11
+ serialize :source_scope, Hash
12
+
13
+ delegate :name, to: :product, prefix: true
14
+
15
+ def accrue_credit!
16
+ return unless accruing?
17
+
18
+ credit = credits.new account: account
19
+ discounts.each { |discount| discount.apply(credit) }
20
+ credit.save!
21
+ end
22
+
23
+ def terminate!(date: Time.current)
24
+ update termination_date: date
25
+ end
26
+
27
+ def terminated?
28
+ return false if termination_date.nil?
29
+
30
+ termination_date.past?
31
+ end
32
+
33
+ def accruing?
34
+ return false unless accruable?
35
+ return true if credits.none?
36
+
37
+ threshold = case product.schedule
38
+ when 'weekly' then 1.week.ago
39
+ when 'monthly' then 1.month.ago
40
+ when 'annually' then 1.year.ago
41
+ end
42
+
43
+ last_accruement_date.before? threshold
44
+ end
45
+
46
+ def accruable?
47
+ return false if terminated?
48
+ return false if account.nil?
49
+
50
+ order_group.complete?
51
+ end
52
+
53
+ def last_accruement_date
54
+ credits.maximum(:created_at)
55
+ end
56
+
57
+ def default_price
58
+ product.price
59
+ end
60
+
61
+ def trigger_callback(trigger)
62
+ source_records.each do |record|
63
+ next unless product.callbacks.has_key? trigger
64
+
65
+ product.callbacks[trigger].each do |callback|
66
+ data = { billable: account.billable, offerable_category: product.offerable_template }
67
+
68
+ arguments = callback[:params]
69
+ keyword_arguments = arguments.extract_options!
70
+
71
+ arguments = data.values_at(*arguments)
72
+ keyword_arguments = keyword_arguments.to_h { |keyword, data_type| [keyword, data[data_type]] }
73
+
74
+ params = arguments
75
+ params << keyword_arguments if keyword_arguments.present?
76
+
77
+ record.public_send(callback[:method_name], *params)
78
+ end
79
+ end
80
+ end
81
+
82
+ def source_records
83
+ return [] if source_scope.empty?
84
+ return [] if product.source_class.nil?
85
+
86
+ product.source_class.where(**source_scope)
87
+ end
88
+ end
@@ -0,0 +1,8 @@
1
+ # TODO: Destroy associated debit when marked as failed
2
+
3
+ class Accountability::Payment < ApplicationRecord
4
+ belongs_to :account
5
+ has_one :debit
6
+
7
+ enum status: %i[pending processing complete failed]
8
+ end
@@ -0,0 +1,53 @@
1
+ # A Product defines any product/service/offering available for users to buy.
2
+ # "Private" products can be made for special offers, one-time deals, or contracts.
3
+ # Terminology:
4
+ # Activation Date - Date at which record becomes usable
5
+ # Expired - No longer available, but still usable by prior adopters
6
+ # Terminated - No longer available - even by prior adopters
7
+
8
+ module Accountability
9
+ class Product < ApplicationRecord
10
+ has_and_belongs_to_many :coupons
11
+ has_many :order_items, dependent: :restrict_with_error
12
+ has_many :credits, through: :order_items, inverse_of: :product
13
+
14
+ serialize :source_scope, Hash
15
+
16
+ enum schedule: %i[one_time weekly monthly annually]
17
+
18
+ def inventory
19
+ return [] if source_class.nil?
20
+
21
+ source_class.where(**source_scope)
22
+ end
23
+
24
+ def offerable_template
25
+ return if offerable_category.nil?
26
+ return @offerable if @offerable.present?
27
+
28
+ offerable = Offerable.collection[offerable_category.to_sym]
29
+
30
+ return offerable if new_record?
31
+
32
+ if offerable.present?
33
+ @offerable = offerable
34
+ else
35
+ raise_offerable_not_found
36
+ end
37
+ end
38
+
39
+ def source_class
40
+ return if offerable_template.nil?
41
+
42
+ offerable_template.class_name.constantize
43
+ end
44
+
45
+ delegate :callbacks, to: :offerable_template
46
+ end
47
+
48
+ private
49
+
50
+ def raise_offerable_not_found
51
+ raise 'Offerable not found'
52
+ end
53
+ end
@@ -0,0 +1,13 @@
1
+ %h1 Accounts
2
+
3
+ %br
4
+
5
+ - @accounts.each do |account|
6
+ %h2
7
+ %b Billable ID:
8
+ = account.billable.id
9
+ %p
10
+ %b Balance:
11
+ = account.balance
12
+ = link_to 'Show', accountability_account_path(account)
13
+ %hr
@@ -0,0 +1,12 @@
1
+ %h1 Account Overview
2
+
3
+ %br
4
+
5
+ %h2
6
+ %b Billable ID:
7
+ = @account.billable.id
8
+ %p
9
+ %b Balance:
10
+ = @account.balance
11
+
12
+ = link_to 'Back', accountability_accounts_path
@@ -0,0 +1,16 @@
1
+ %h1 Shopping Cart
2
+ = link_to 'Back', accountability_products_path
3
+
4
+ %p
5
+ %b Cart ID:
6
+ = @order_group.id
7
+
8
+ - @order_group.order_items.includes(:product).each do |order_item|
9
+ %hr
10
+ %b= order_item.product_name
11
+ %br
12
+ %p
13
+ Price:
14
+ = order_item.default_price
15
+
16
+ = button_to 'Check Out', '#'
@@ -0,0 +1 @@
1
+ <h1>Product#edit</h1>
@@ -0,0 +1,39 @@
1
+ %h1 Products
2
+
3
+ = link_to 'Shopping Cart', accountability_order_group_path(current_order_group)
4
+ |
5
+ = link_to 'New Product', new_accountability_product_path
6
+
7
+ %p
8
+ %b User is admin:
9
+ = admin_session? ? 'yep' : 'nope'
10
+
11
+ -# Temporary - we shouldn't assume they're on Devise
12
+ %p
13
+ %b Email:
14
+ = current_user&.email
15
+
16
+ %p
17
+ %b Account ID (session):
18
+ = session[:current_account_id]
19
+
20
+ %p
21
+ %b Account (db):
22
+ = current_account
23
+
24
+ %p
25
+ %b OrderGroup ID (db):
26
+ = current_order_group&.id
27
+
28
+ %br
29
+ %br
30
+ %br
31
+ %br
32
+ %br
33
+ %hr
34
+
35
+ - @products.each do |product|
36
+ %h2= product.name
37
+ %p= product.description
38
+ = button_to 'Add to cart', add_item_accountability_order_group_path(current_order_group, product_id: product.id)
39
+ %hr