accountability 0.1.1 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
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