accountability 0.1.1 → 0.2.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +86 -9
  3. data/app/assets/stylesheets/accountability/application.scss +0 -0
  4. data/app/controllers/accountability/accounts_controller.rb +2 -4
  5. data/app/controllers/accountability/billing_configurations_controller.rb +80 -0
  6. data/app/controllers/accountability/order_groups_controller.rb +2 -4
  7. data/app/controllers/accountability/payments_controller.rb +25 -0
  8. data/app/controllers/accountability/products_controller.rb +7 -5
  9. data/app/controllers/accountability/statements_controller.rb +19 -0
  10. data/app/controllers/accountability_controller.rb +47 -0
  11. data/app/helpers/accountability/{application_helper.rb → accountability_helper.rb} +1 -1
  12. data/app/helpers/accountability/billing_configurations_helper.rb +47 -0
  13. data/app/helpers/accountability/products_helper.rb +21 -0
  14. data/app/models/accountability/account.rb +31 -1
  15. data/app/models/accountability/application_record.rb +24 -0
  16. data/app/models/accountability/billing_configuration.rb +41 -0
  17. data/app/models/accountability/coupon.rb +2 -0
  18. data/app/models/accountability/credit.rb +53 -29
  19. data/app/models/accountability/debit.rb +10 -0
  20. data/app/models/accountability/inventory.rb +61 -0
  21. data/app/models/accountability/offerable.rb +28 -1
  22. data/app/models/accountability/offerable/scope.rb +48 -0
  23. data/app/models/accountability/order_item.rb +3 -7
  24. data/app/models/accountability/payment.rb +22 -3
  25. data/app/models/accountability/product.rb +45 -5
  26. data/app/models/accountability/statement.rb +46 -0
  27. data/app/models/accountability/{account/transactions.rb → transactions.rb} +14 -2
  28. data/app/models/concerns/accountability/active_merchant_interface.rb +15 -0
  29. data/app/models/concerns/accountability/active_merchant_interface/stripe_interface.rb +134 -0
  30. data/app/pdfs/statement_pdf.rb +91 -0
  31. data/app/views/accountability/accounts/show.html.haml +21 -9
  32. data/app/views/accountability/products/index.html.haml +17 -34
  33. data/app/views/accountability/products/new.html.haml +73 -0
  34. data/app/views/accountability/shared/_session_info.html.haml +24 -0
  35. data/config/locales/en.yml +19 -0
  36. data/db/migrate/20190814000455_create_accountability_tables.rb +43 -1
  37. data/lib/accountability.rb +2 -1
  38. data/lib/accountability/configuration.rb +22 -2
  39. data/lib/accountability/engine.rb +6 -1
  40. data/lib/accountability/extensions/acts_as_billable.rb +11 -0
  41. data/lib/accountability/rails/routes.rb +72 -0
  42. data/lib/accountability/types.rb +9 -0
  43. data/lib/accountability/types/billing_configuration_types.rb +57 -0
  44. data/lib/accountability/version.rb +1 -1
  45. data/lib/generators/accountability/install_generator.rb +47 -0
  46. data/lib/generators/accountability/templates/migration.rb.tt +155 -0
  47. data/lib/generators/accountability/templates/price_overrides_migration.rb.tt +13 -0
  48. metadata +73 -12
  49. data/app/assets/stylesheets/accountability/application.css +0 -15
  50. data/app/controllers/accountability/application_controller.rb +0 -45
  51. data/app/views/accountability/products/new.html.erb +0 -40
  52. data/lib/accountability/cartographer.rb +0 -28
@@ -4,6 +4,7 @@ module Accountability
4
4
  class Account < ApplicationRecord
5
5
  belongs_to :billable, polymorphic: true
6
6
 
7
+ has_many :statements, dependent: :destroy
7
8
  has_many :order_groups, dependent: :destroy
8
9
  has_many :order_items, through: :order_groups, inverse_of: :account
9
10
  has_many :purchased_order_groups, -> { complete }, class_name: 'OrderGroup', inverse_of: :account
@@ -11,6 +12,22 @@ module Accountability
11
12
  has_many :payments, dependent: :nullify
12
13
  has_many :credits, dependent: :destroy
13
14
  has_many :debits, dependent: :destroy
15
+ has_many :billing_configurations, dependent: :destroy
16
+
17
+ enum statement_schedule: %i[end_of_month bi_weekly]
18
+
19
+ def build_billing_configuration_with_active_merchant_data(billing_configuration_params, **options)
20
+ billing_configuration = billing_configurations.build(billing_configuration_params)
21
+ # We don't want to run this too often as it does create a charge against the card when verify_card is true.
22
+ # so we'll only run this when the config is valid.
23
+ billing_configuration.store_active_merchant_data(**options) if billing_configuration.valid?
24
+ billing_configuration
25
+ end
26
+
27
+ def billable_record_name
28
+ name_method = Configuration.billable_name_column
29
+ billable.public_send(name_method)
30
+ end
14
31
 
15
32
  def balance
16
33
  accrued_credits = credits.sum(:amount)
@@ -19,10 +36,23 @@ module Accountability
19
36
  accrued_debits - accrued_credits
20
37
  end
21
38
 
39
+ def balanced?
40
+ balance >= 0.00
41
+ end
42
+
22
43
  def transactions
23
- associated_credits = credits.includes(:product, :deductions)
44
+ associated_credits = credits.includes(:product, deductions: :discount).references(:order_item)
24
45
 
25
46
  Transactions.new(debits: debits, credits: associated_credits)
26
47
  end
48
+
49
+ def current_statement
50
+ latest_statement = statements.last
51
+
52
+ return if latest_statement.nil?
53
+ return if latest_statement.past?
54
+
55
+ latest_statement
56
+ end
27
57
  end
28
58
  end
@@ -1,3 +1,27 @@
1
1
  class Accountability::ApplicationRecord < ActiveRecord::Base
2
2
  self.abstract_class = true
3
+
4
+ before_validation :validate_validatable_attributes
5
+ cattr_accessor :validatable_attribute_names, default: []
6
+
7
+ def self.validates_attributes(*attribute_names)
8
+ self.validatable_attribute_names = attribute_names
9
+ end
10
+
11
+ private
12
+
13
+ def validate_validatable_attributes
14
+ validatable_attribute_names.each do |attribute_name|
15
+ attribute = public_send attribute_name
16
+
17
+ next if attribute.blank?
18
+
19
+ attribute.validate
20
+
21
+ attribute.errors.each do |sub_attribute_name, error_message|
22
+ target = "#{attribute_name}.#{sub_attribute_name}".to_sym
23
+ errors.add target, error_message
24
+ end
25
+ end
26
+ end
3
27
  end
@@ -0,0 +1,41 @@
1
+ module Accountability
2
+ class BillingConfiguration < ApplicationRecord
3
+ include Accountability::ActiveMerchantInterface
4
+ after_initialize :set_provider, if: :new_record?
5
+
6
+ belongs_to :account
7
+ has_many :payments, dependent: :restrict_with_error
8
+
9
+ attribute :billing_address, :billing_address, default: {}
10
+ attribute :active_merchant_data, :json, default: {}
11
+
12
+ enum provider: %i[unselected stripe]
13
+
14
+ scope :primary, -> { where primary: true }
15
+
16
+ validates :configuration_name, presence: true
17
+ validates :contact_first_name, :contact_last_name,
18
+ format: { with: Regex::LETTERS_AND_NUMBERS, message: :invalid }, presence: true
19
+ validates :contact_email, format: { with: Regex::EMAIL_ADDRESS, message: :invalid }
20
+ validates_attributes :billing_address
21
+
22
+ def contact_name
23
+ "#{contact_first_name} #{contact_last_name}"
24
+ end
25
+
26
+ def primary!
27
+ return if primary?
28
+
29
+ transaction do
30
+ account.billing_configurations.primary.update_all(primary: false) # rubocop:disable Rails/SkipsModelValidations
31
+ update!(primary: true)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def set_provider
38
+ self.provider = Configuration.payment_gateway[:provider]
39
+ end
40
+ end
41
+ end
@@ -1,3 +1,5 @@
1
+ # rubocop:disable Rails/HasAndBelongsToMany
2
+
1
3
  class Accountability::Coupon < ApplicationRecord
2
4
  has_and_belongs_to_many :products
3
5
  has_many :discounts, dependent: :restrict_with_error
@@ -1,46 +1,70 @@
1
1
  # A Credit represents a single charge to an Account
2
2
  # To preserve data integrity, credits should never be modified
3
3
 
4
- class Accountability::Credit < ApplicationRecord
5
- before_validation :set_amount
4
+ module Accountability
5
+ class Credit < ApplicationRecord
6
+ before_validation :set_amount, :set_statement
6
7
 
7
- belongs_to :account
8
- belongs_to :order_item
9
- has_many :deductions, dependent: :destroy
10
- has_one :product, through: :order_item, inverse_of: :credits
8
+ belongs_to :account
9
+ belongs_to :statement
10
+ belongs_to :order_item
11
+ has_many :deductions, dependent: :destroy
12
+ has_one :product, through: :order_item, inverse_of: :credits
11
13
 
12
- validate :validate_amount_unchanged
14
+ validates :amount, :taxes, presence: true
15
+ validate :validate_amount_unchanged
13
16
 
14
- delegate :name, to: :product, prefix: true
17
+ delegate :name, to: :product, prefix: true
15
18
 
16
- def base_price
17
- if new_record?
18
- order_item.default_price
19
- else
20
- amount + total_deductions
19
+ def base_price
20
+ if new_record?
21
+ order_item.default_price
22
+ else
23
+ amount + total_deductions + taxes
24
+ end
21
25
  end
22
- end
23
26
 
24
- def total_deductions
25
- if persisted?
26
- deductions.sum(:amount)
27
- else
28
- deductions.map(&:amount).sum
27
+ def total_deductions
28
+ if persisted?
29
+ deductions.sum(:amount)
30
+ else
31
+ deductions.map(&:amount).sum
32
+ end
29
33
  end
30
- end
31
34
 
32
- private
35
+ private
33
36
 
34
- def set_amount
35
- return if persisted?
37
+ def set_amount
38
+ return if persisted?
36
39
 
37
- self.amount = base_price - total_deductions
38
- end
40
+ pre_tax_total = base_price - total_deductions
41
+
42
+ self.taxes = if product.tax_exempt?
43
+ 0.00
44
+ else
45
+ pre_tax_total * Configuration.tax_rate / 100
46
+ end
47
+
48
+ self.amount = pre_tax_total - taxes
49
+ end
39
50
 
40
- def validate_amount_unchanged
41
- return if new_record?
42
- return unless amount_changed?
51
+ def set_statement
52
+ return if statement.present?
43
53
 
44
- errors.add(:amount, 'cannot be changed')
54
+ current_statement = account.current_statement
55
+
56
+ if current_statement.present?
57
+ self.statement = current_statement
58
+ else
59
+ build_statement account: account
60
+ end
61
+ end
62
+
63
+ def validate_amount_unchanged
64
+ return if new_record?
65
+ return unless amount_changed?
66
+
67
+ errors.add(:amount, 'cannot be changed')
68
+ end
45
69
  end
46
70
  end
@@ -1,5 +1,9 @@
1
+ # We reset the account's :last_balanced_at after a debit is created.
2
+ # It is very important to make sure that the value is always accurate.
3
+
1
4
  class Accountability::Debit < ApplicationRecord
2
5
  before_validation :set_amount
6
+ after_create :update_last_balanced_at!
3
7
 
4
8
  belongs_to :account
5
9
  belongs_to :payment, optional: true
@@ -11,4 +15,10 @@ class Accountability::Debit < ApplicationRecord
11
15
 
12
16
  self.amount = payment.amount
13
17
  end
18
+
19
+ def update_last_balanced_at!
20
+ return unless account.balanced?
21
+
22
+ account.update(last_balanced_at: created_at)
23
+ end
14
24
  end
@@ -0,0 +1,61 @@
1
+ require 'forwardable'
2
+
3
+ # The InventoryItems class is conceptually comparable to ActiveRecord's CollectionProxy.
4
+ # It provides an interface for interacting with inventory records within a product's scope.
5
+ #
6
+ # This is an improvement over the original approach of returning a source_class::ActiveRecord_Relation object which
7
+ # lacked any information related to the Accountability::Product record itself.
8
+ #
9
+ # Usage:
10
+ # inventory = Inventory.new(product)
11
+ # inventory.active.count
12
+
13
+ module Accountability
14
+ class Inventory
15
+ extend Forwardable
16
+
17
+ attr_accessor :product
18
+
19
+ def_delegators :product, :source_class, :source_scope, :offerable_template
20
+ def_delegators :collection, :to_s, :each, :first, :last, :sort_by, :any?, :none?, :count
21
+
22
+ def initialize(product, available_only: false)
23
+ @product = product
24
+ @scope_availability = available_only
25
+ end
26
+
27
+ def collection
28
+ records = source_class.where(**source_scope)
29
+ records = records.public_send(offerable_template.whitelist) if scope_availability?
30
+
31
+ records.map { |record| InventoryItem.new(record: record, product: product) }
32
+ end
33
+
34
+ def available
35
+ @scope_availability = true
36
+
37
+ self
38
+ end
39
+
40
+ private
41
+
42
+ class InventoryItem
43
+ attr_accessor :record, :product
44
+
45
+ def initialize(record:, product:)
46
+ @record = record
47
+ @product = product
48
+ end
49
+
50
+ def price
51
+ product.price
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def scope_availability?
58
+ @scope_availability
59
+ end
60
+ end
61
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  class Accountability::Offerable
4
4
  cattr_accessor :collection, default: {}
5
- attr_accessor :tenant, :category, :class_name, :trait, :scopes, :properties, :callbacks
5
+ attr_accessor :tenant, :category, :class_name, :trait, :scopes, :properties, :callbacks, :whitelist
6
6
 
7
7
  def initialize(category, tenant: :default, trait: nil, class_name:)
8
8
  @category = category
@@ -12,6 +12,7 @@ class Accountability::Offerable
12
12
  @scopes = {}
13
13
  @properties = {}
14
14
  @callbacks = {}
15
+ @whitelist = :all
15
16
  end
16
17
 
17
18
  def self.add(category, tenant: :default, trait: nil, class_name:)
@@ -22,18 +23,44 @@ class Accountability::Offerable
22
23
  offerable
23
24
  end
24
25
 
26
+ # Used when creating a product to define queries identifying saleable records from the host table.
27
+ # This can be used to de-scope sold inventory, but it is recommended to define a whitelist for that instead.
28
+ #
29
+ # CONSIDER: Add a parameter for indicating optional scopes
25
30
  def add_scope(name, title: name, options: :auto)
26
31
  scopes[name] = { title: title.to_s, options: options, category: category }
27
32
 
28
33
  self
29
34
  end
30
35
 
36
+ # Used for limiting the product's full inventory scope to a subset.
37
+ # Non-whitelisted records will be treated the same as a private product's inventory.
38
+ #
39
+ # The method takes the name of a pre-defined ActiveRecord scope as an argument.
40
+ # If no whitelist is specified, :all will be used instead.
41
+ def inventory_whitelist(whitelist_scope)
42
+ self.whitelist = whitelist_scope.to_sym
43
+
44
+ self
45
+ end
46
+
47
+ # Used to differentiate records within a product's scope/inventory.
48
+ # For example, consider a co-location that allows customers to choose their own cabinet:
49
+ # `offer.add_property :cabinet_location_column, title: 'Asset Tag'`
50
+ #
51
+ # Use the `position` column if it is important that the properties display in a specific order.
52
+ #
53
+ # CONSIDER: Add support for property-specific pricing
31
54
  def add_property(name, title: name, position: nil)
32
55
  position = properties.size.next if position.nil?
33
56
  properties[name] = { title: title.to_s, position: position }
57
+
34
58
  self
35
59
  end
36
60
 
61
+ # Used for setting multiple properties in a single line.
62
+ # Column names will be used as titles, and property order retained.
63
+ # `offer.add_properties :asset_tag, :room_number, :color`
37
64
  def add_properties(*names)
38
65
  names.each do |name|
39
66
  add_property(name)
@@ -0,0 +1,48 @@
1
+ # Scope objects represent an offerable's scoping options as defined by `offer.add_scope`
2
+ # The plan is to replace the Offerable#scopes hash attribute with an array of scope objects
3
+
4
+ class Accountability::Offerable::Scope
5
+ attr_accessor :source_class, :category, :attribute, :title
6
+ attr_writer :options
7
+
8
+ def initialize(source_class:, category:, attribute:, title: nil, options: :auto)
9
+ @source_class = source_class
10
+ @category = category
11
+ @attribute = attribute
12
+ @title = title.presence || attribute.to_s.titleize
13
+ @options = options
14
+ end
15
+
16
+ # Returns an array of values to choose from when defining the scope for a new product.
17
+ # The product's `source_scope` column stores a hash mapping each scopes' @attribute with the selected options.
18
+ #
19
+ # For example, if an offerable called 'Bucket' has a scoped :color attribute with options %w[red green black grey],
20
+ # a "colored bucket" product can be created with a `source_scope` of `{ color: %w[red green] }`.
21
+ # Buckets available for purchase would be automatically found by querying `Bucket.where(color: %w[red green])`.
22
+ #
23
+ # When an offerable's scope has no options, it defaults to `:auto` and is generated automatically based on the
24
+ # attribute type when `#options` is called instead.
25
+ # String - Returns a unique array of values plucked from the attribute's column
26
+ # Enum - Returns am array containing each valid enum value
27
+ # Boolean - Returns [true, false]
28
+ # Any other attribute type will return an empty array.
29
+ def options
30
+ return @options unless @options == :auto
31
+
32
+ @options = case attribute_type
33
+ when ActiveModel::Type::String
34
+ source_class.distinct.pluck(attribute)
35
+ when ActiveRecord::Enum::EnumType
36
+ enums = source_class.defined_enums.with_indifferent_access
37
+ enums[attribute].keys
38
+ when ActiveModel::Type::Boolean
39
+ [true, false]
40
+ else
41
+ []
42
+ end
43
+ end
44
+
45
+ def attribute_type
46
+ source_class.type_for_attribute(attribute)
47
+ end
48
+ end
@@ -33,14 +33,10 @@ class Accountability::OrderItem < ApplicationRecord
33
33
  def accruing?
34
34
  return false unless accruable?
35
35
  return true if credits.none?
36
+ return false if product.accrues_one_time?
36
37
 
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
38
+ billing_cycle_threshold = product.billing_cycle_length.ago
39
+ last_accruement_date.before? billing_cycle_threshold
44
40
  end
45
41
 
46
42
  def accruable?
@@ -1,8 +1,27 @@
1
- # TODO: Destroy associated debit when marked as failed
2
-
3
1
  class Accountability::Payment < ApplicationRecord
2
+ after_validation :process_transaction!, if: :pending?
3
+
4
4
  belongs_to :account
5
- has_one :debit
5
+ belongs_to :billing_configuration
6
+
7
+ has_one :debit, dependent: :restrict_with_error
6
8
 
7
9
  enum status: %i[pending processing complete failed]
10
+
11
+ validates :amount, presence: true, numericality: { greater_than: 10.00 }
12
+
13
+ def process_transaction!
14
+ return if debit.present?
15
+ return if errors.present?
16
+
17
+ if billing_configuration.charge(amount)
18
+ self.status = :complete
19
+ build_debit(account: account, amount: amount)
20
+ elsif billing_configuration.invalid?
21
+ self.status = :failed
22
+ billing_configuration.errors.full_messages.each { |error| errors.add(:base, error.titleize) }
23
+ else
24
+ self.status = :failed
25
+ end
26
+ end
8
27
  end